Click Tooltip
Original · freeclick-to-reveal tooltip
byWeb InnoventixReact + Tailwind
tipclicktooltips
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/tip-click.jsontip-click.tsx
"use client";
import { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Placement = "top" | "bottom" | "left" | "right";
type Setting = {
id: string;
term: string;
value: string;
body: string;
};
const SETTINGS: Setting[] = [
{
id: "framework",
term: "Framework preset",
value: "Next.js",
body: "Detected automatically from your lockfile and config. It sets sensible defaults for the build command and output directory, so most projects never need to touch the fields below.",
},
{
id: "build",
term: "Build command",
value: "next build",
body: "Runs after dependencies install to produce your production bundle. Leave it on the preset default unless your project compiles through a custom script.",
},
{
id: "output",
term: "Output directory",
value: ".next",
body: "The folder served as your site once the build succeeds. If a deploy renders a blank page, this setting is almost always pointing at the wrong folder.",
},
{
id: "install",
term: "Install command",
value: "npm install",
body: "Executed before the build to fetch dependencies. Switch it to pnpm or yarn here when your lockfile is not package-lock.json.",
},
{
id: "node",
term: "Node.js version",
value: "20.x",
body: "The runtime used for both builds and serverless functions. Pin it to the same major version you run locally to avoid surprises at deploy time.",
},
{
id: "root",
term: "Root directory",
value: "./",
body: "Point this at a subfolder when your app lives inside a monorepo. Every other path on this page is then resolved relative to it.",
},
];
const PLACEMENTS: { value: Placement; label: string }[] = [
{ value: "top", label: "Top" },
{ value: "bottom", label: "Bottom" },
{ value: "left", label: "Left" },
{ value: "right", label: "Right" },
];
const PANEL_POS: Record<Placement, string> = {
top: "bottom-full left-1/2 -translate-x-1/2 mb-2.5",
bottom: "top-full left-1/2 -translate-x-1/2 mt-2.5",
left: "right-full top-1/2 -translate-y-1/2 mr-2.5",
right: "left-full top-1/2 -translate-y-1/2 ml-2.5",
};
const ARROW_POS: Record<Placement, string> = {
top: "-bottom-1 left-1/2 -translate-x-1/2",
bottom: "-top-1 left-1/2 -translate-x-1/2",
left: "-right-1 top-1/2 -translate-y-1/2",
right: "-left-1 top-1/2 -translate-y-1/2",
};
const OFFSETS: Record<Placement, { x?: number; y?: number }> = {
top: { y: 6 },
bottom: { y: -6 },
left: { x: 6 },
right: { x: -6 },
};
function InfoIcon() {
return (
<svg
viewBox="0 0 20 20"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<circle cx="10" cy="10" r="8" />
<path d="M10 9.25v4" strokeLinecap="round" />
<circle cx="10" cy="6.4" r="0.9" fill="currentColor" stroke="none" />
</svg>
);
}
type ToggletipProps = {
setting: Setting;
placement: Placement;
isOpen: boolean;
reduce: boolean;
hint: boolean;
onToggle: (id: string) => void;
onClose: () => void;
};
function Toggletip({
setting,
placement,
isOpen,
reduce,
hint,
onToggle,
onClose,
}: ToggletipProps) {
const offset = OFFSETS[placement];
const initial = reduce
? { opacity: 0 }
: { opacity: 0, scale: 0.94, ...offset };
const exit = reduce ? { opacity: 0 } : { opacity: 0, scale: 0.94, ...offset };
return (
<span
className="relative inline-flex"
onBlur={(event) => {
if (
isOpen &&
!event.currentTarget.contains(event.relatedTarget as Node | null)
) {
onClose();
}
}}
>
<button
type="button"
aria-label={`About ${setting.term}`}
aria-expanded={isOpen}
onClick={() => onToggle(setting.id)}
onKeyDown={(event) => {
if (event.key === "Escape" && isOpen) {
event.stopPropagation();
onClose();
}
}}
className={`inline-flex h-5 w-5 items-center justify-center rounded-full border transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
isOpen
? "border-indigo-500 bg-indigo-500 text-white"
: "border-slate-300 text-slate-400 hover:border-indigo-400 hover:text-indigo-600 dark:border-slate-600 dark:text-slate-400 dark:hover:border-indigo-400 dark:hover:text-indigo-300"
} ${hint && !isOpen ? "tipclk-hint" : ""}`}
>
<InfoIcon />
</button>
<span
role="status"
aria-live="polite"
aria-atomic="true"
className={`pointer-events-none absolute z-30 ${PANEL_POS[placement]}`}
>
<AnimatePresence>
{isOpen ? (
<motion.span
key="panel"
initial={initial}
animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
exit={exit}
transition={{ duration: reduce ? 0.12 : 0.18, ease: "easeOut" }}
className="pointer-events-auto relative block w-60 max-w-[calc(100vw-3rem)] rounded-xl border border-slate-200 bg-white p-3.5 text-left shadow-xl shadow-slate-900/10 dark:border-slate-700 dark:bg-slate-900 dark:shadow-black/40"
>
<span className="block text-[0.65rem] font-semibold uppercase tracking-wider text-indigo-600 dark:text-indigo-400">
{setting.term}
</span>
<span className="mt-1 block text-sm leading-relaxed text-slate-600 dark:text-slate-300">
{setting.body}
</span>
<span
aria-hidden="true"
className={`absolute block h-2 w-2 rotate-45 rounded-[1px] bg-white shadow-sm dark:bg-slate-900 ${ARROW_POS[placement]}`}
/>
</motion.span>
) : null}
</AnimatePresence>
</span>
</span>
);
}
function PlacementControl({
value,
onChange,
}: {
value: Placement;
onChange: (next: Placement) => void;
}) {
const buttons: (HTMLButtonElement | null)[] = [];
const move = (dir: number) => {
const index = PLACEMENTS.findIndex((p) => p.value === value);
const nextIndex =
(index + dir + PLACEMENTS.length) % PLACEMENTS.length;
onChange(PLACEMENTS[nextIndex].value);
buttons[nextIndex]?.focus();
};
return (
<div
role="radiogroup"
aria-label="Reveal direction"
className="inline-flex rounded-lg border border-slate-200 bg-slate-100 p-0.5 dark:border-slate-700 dark:bg-slate-800/60"
>
{PLACEMENTS.map((placement, index) => {
const active = placement.value === value;
return (
<button
key={placement.value}
ref={(element) => {
buttons[index] = element;
}}
type="button"
role="radio"
aria-checked={active}
tabIndex={active ? 0 : -1}
onClick={() => onChange(placement.value)}
onKeyDown={(event) => {
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
event.preventDefault();
move(1);
} else if (
event.key === "ArrowLeft" ||
event.key === "ArrowUp"
) {
event.preventDefault();
move(-1);
}
}}
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 ${
active
? "bg-white text-slate-900 shadow-sm dark:bg-slate-900 dark:text-white"
: "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200"
}`}
>
{placement.label}
</button>
);
})}
</div>
);
}
export default function TipClick() {
const [openId, setOpenId] = useState<string | null>(null);
const [hasInteracted, setHasInteracted] = useState(false);
const [placement, setPlacement] = useState<Placement>("top");
const reduce = Boolean(useReducedMotion());
const toggle = (id: string) => {
setHasInteracted(true);
setOpenId((prev) => (prev === id ? null : id));
};
const close = () => setOpenId(null);
return (
<section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 to-white px-6 py-20 sm:py-28 dark:from-slate-950 dark:to-slate-900">
<style>{`
@keyframes tipclk-pulse {
0% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.45); }
70% { box-shadow: 0 0 0 8px rgba(99, 102, 241, 0); }
100% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0); }
}
.tipclk-hint { animation: tipclk-pulse 2.2s ease-out infinite; }
@media (prefers-reduced-motion: reduce) {
.tipclk-hint { animation: none; }
}
`}</style>
<div
aria-hidden="true"
className="pointer-events-none absolute -top-24 left-1/2 h-72 w-72 -translate-x-1/2 rounded-full bg-indigo-300/20 blur-3xl dark:bg-indigo-500/10"
/>
<div className="relative mx-auto max-w-2xl">
<div className="mb-8 text-center">
<span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium text-slate-600 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
Deploy settings
</span>
<h2 className="mt-4 text-balance text-3xl font-semibold tracking-tight text-slate-900 sm:text-4xl dark:text-white">
Every field, explained on demand
</h2>
<p className="mx-auto mt-3 max-w-md text-pretty text-sm leading-relaxed text-slate-600 dark:text-slate-400">
Tooltips that reveal only on click keep this form calm to scan. Open
an information button for the detail, then press Escape or click away
to dismiss it.
</p>
</div>
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900/60">
<div className="flex flex-col gap-3 border-b border-slate-200 px-5 py-4 sm:flex-row sm:items-center sm:justify-between dark:border-slate-800">
<div>
<h3 className="text-sm font-semibold text-slate-900 dark:text-white">
Production configuration
</h3>
<p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">
acme-web · main branch
</p>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500 dark:text-slate-400">
Reveal from
</span>
<PlacementControl value={placement} onChange={setPlacement} />
</div>
</div>
<ul className="divide-y divide-slate-200 dark:divide-slate-800">
{SETTINGS.map((setting, index) => (
<li
key={setting.id}
className="flex items-center justify-between gap-4 px-5 py-4"
>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-800 dark:text-slate-200">
{setting.term}
</span>
<Toggletip
setting={setting}
placement={placement}
isOpen={openId === setting.id}
reduce={reduce}
hint={index === 0 && !hasInteracted}
onToggle={toggle}
onClose={close}
/>
</div>
<code className="shrink-0 rounded-md bg-slate-100 px-2 py-1 font-mono text-xs text-slate-700 dark:bg-slate-800 dark:text-slate-300">
{setting.value}
</code>
</li>
))}
</ul>
<div className="border-t border-slate-200 px-5 py-3 dark:border-slate-800">
<p className="text-xs text-slate-500 dark:text-slate-400">
Press{" "}
<kbd className="rounded border border-slate-300 bg-slate-50 px-1.5 py-0.5 font-mono text-[0.65rem] text-slate-600 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300">
Esc
</kbd>{" "}
or click outside to close a tip.
</p>
</div>
</div>
</div>
</section>
);
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Basic Tooltip
Originalbasic tooltips on hover

Arrow Tooltip
Originaltooltips with an arrow

Directions Tooltip
Originaltooltips in four directions

Rich Tooltip
Originalrich tooltip with title and body

Follow Cursor Tooltip
Originaltooltip that follows the cursor

Popover Tooltip
Originalclick popover with content

Popover Form Tooltip
Originalpopover containing a mini form

Hover Card Tooltip
Originalprofile hover card

Keyboard Hint Tooltip
Originalkeyboard shortcut hint tooltips

Color Tooltip
Originalcoloured semantic tooltips

Glass Tooltip
Originalglassmorphic tooltips

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

