Directions Tooltip
Original · freetooltips in four directions
byWeb InnoventixReact + Tailwind
tipdirectionstooltips
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-directions.jsontip-directions.tsx
"use client";
import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import { motion, AnimatePresence, useReducedMotion } from "motion/react";
type Placement = "top" | "right" | "bottom" | "left";
const PLACEMENTS: Placement[] = ["top", "right", "bottom", "left"];
const PLACEMENT_LABEL: Record<Placement, string> = {
top: "Top",
right: "Right",
bottom: "Bottom",
left: "Left",
};
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 OFFSET: Record<Placement, { x: number; y: number }> = {
top: { x: 0, y: 6 },
bottom: { x: 0, y: -6 },
left: { x: 6, y: 0 },
right: { x: -6, y: 0 },
};
function Tooltip({
content,
placement,
pinned = false,
children,
}: {
content: ReactNode;
placement: Placement;
pinned?: boolean;
children: ReactNode;
}) {
const reduce = useReducedMotion() ?? false;
const [hoverOpen, setHoverOpen] = useState(false);
const tipId = useId();
const open = hoverOpen || pinned;
const off = OFFSET[placement];
const from = reduce
? { opacity: 0, x: 0, y: 0, scale: 1 }
: { opacity: 0, x: off.x, y: off.y, scale: 0.94 };
const to = { opacity: 1, x: 0, y: 0, scale: 1 };
function onKeyDown(e: KeyboardEvent<HTMLButtonElement>) {
if (e.key === "Escape" && hoverOpen) {
e.stopPropagation();
setHoverOpen(false);
}
}
return (
<span className="relative inline-flex">
<button
type="button"
aria-describedby={open ? tipId : undefined}
onMouseEnter={() => setHoverOpen(true)}
onMouseLeave={() => setHoverOpen(false)}
onFocus={() => setHoverOpen(true)}
onBlur={() => setHoverOpen(false)}
onKeyDown={onKeyDown}
className="group inline-flex items-center gap-2 rounded-xl border border-slate-300/80 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 shadow-sm transition-colors hover:border-indigo-400 hover:text-indigo-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-200 dark:hover:border-indigo-400 dark:hover:text-indigo-300 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-neutral-950"
>
{children}
</button>
<AnimatePresence>
{open && (
<motion.span
id={tipId}
role="tooltip"
initial={from}
animate={to}
exit={from}
transition={{ duration: reduce ? 0 : 0.17, ease: [0.16, 1, 0.3, 1] }}
className={`pointer-events-none absolute z-30 w-max max-w-[15rem] rounded-lg bg-slate-900 px-3 py-2 text-center text-xs font-medium leading-relaxed text-slate-50 shadow-xl shadow-slate-900/25 ring-1 ring-black/5 dark:bg-white dark:text-slate-900 dark:shadow-black/40 dark:ring-white/10 ${PANEL_POS[placement]}`}
>
{content}
<span
aria-hidden="true"
className={`absolute h-2 w-2 rotate-45 bg-slate-900 dark:bg-white ${ARROW_POS[placement]}`}
/>
</motion.span>
)}
</AnimatePresence>
</span>
);
}
function DirChevron({ placement }: { placement: Placement }) {
const rot: Record<Placement, string> = {
top: "rotate-0",
right: "rotate-90",
bottom: "rotate-180",
left: "-rotate-90",
};
return (
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className={`h-4 w-4 ${rot[placement]}`}
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 19V5" />
<path d="M5 12l7-7 7 7" />
</svg>
);
}
const DEMOS: { placement: Placement; label: string; icon: ReactNode; content: string }[] = [
{
placement: "top",
label: "Autosave",
content: "Your work saves automatically every 30 seconds.",
icon: (
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2Z" />
<path d="M17 21v-8H7v8M7 3v5h8" />
</svg>
),
},
{
placement: "right",
label: "Invite",
content: "Add up to five teammates to this project.",
icon: (
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M19 8v6M22 11h-6" />
</svg>
),
},
{
placement: "bottom",
label: "Export",
content: "Download as PDF, PNG, or Markdown.",
icon: (
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<path d="M7 10l5 5 5-5M12 15V3" />
</svg>
),
},
{
placement: "left",
label: "History",
content: "Restore any version from the last 30 days.",
icon: (
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M3 3v5h5" />
<path d="M3.05 13A9 9 0 1 0 6 5.3L3 8" />
<path d="M12 7v5l4 2" />
</svg>
),
},
];
export default function TipDirections() {
const reduce = useReducedMotion() ?? false;
const [placement, setPlacement] = useState<Placement>("top");
const [pinned, setPinned] = useState(false);
const radioRefs = useRef<(HTMLButtonElement | null)[]>([]);
function onRadioKey(e: KeyboardEvent<HTMLButtonElement>, idx: number) {
let next = idx;
if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (idx + 1) % PLACEMENTS.length;
else if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (idx - 1 + PLACEMENTS.length) % PLACEMENTS.length;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = PLACEMENTS.length - 1;
else return;
e.preventDefault();
setPlacement(PLACEMENTS[next]);
radioRefs.current[next]?.focus();
}
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-5 py-20 text-slate-900 sm:px-8 dark:bg-neutral-950 dark:text-neutral-100">
<style>{`
@keyframes tipdir-drift {
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
50% { transform: translate3d(0, -22px, 0) scale(1.08); }
}
.tipdir-blob { animation: tipdir-drift 14s ease-in-out infinite; }
.tipdir-blob-2 { animation: tipdir-drift 18s ease-in-out infinite reverse; }
@media (prefers-reduced-motion: reduce) {
.tipdir-blob, .tipdir-blob-2 { animation: none !important; }
}
`}</style>
{/* ambient background */}
<div aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="tipdir-blob absolute -left-16 top-10 h-72 w-72 rounded-full bg-indigo-300/30 blur-3xl dark:bg-indigo-600/20" />
<div className="tipdir-blob-2 absolute -right-10 bottom-0 h-80 w-80 rounded-full bg-violet-300/30 blur-3xl dark:bg-violet-700/20" />
<div
className="absolute inset-0 opacity-[0.04] dark:opacity-[0.06]"
style={{
backgroundImage:
"linear-gradient(currentColor 1px, transparent 1px), linear-gradient(90deg, currentColor 1px, transparent 1px)",
backgroundSize: "44px 44px",
}}
/>
</div>
<div className="relative mx-auto max-w-5xl">
<header className="mx-auto max-w-2xl text-center">
<span className="inline-flex items-center gap-2 rounded-full border border-slate-300/70 bg-white/70 px-3 py-1 text-xs font-semibold uppercase tracking-widest text-indigo-600 backdrop-blur dark:border-neutral-700 dark:bg-neutral-900/70 dark:text-indigo-300">
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500 dark:bg-indigo-400" />
Tooltips
</span>
<h2 className="mt-5 text-balance text-3xl font-semibold tracking-tight sm:text-4xl">
Point in any of four directions
</h2>
<p className="mx-auto mt-3 max-w-xl text-pretty text-sm leading-relaxed text-slate-600 sm:text-base dark:text-neutral-400">
Accessible tooltips that appear on hover and keyboard focus, dismiss with{" "}
<kbd className="rounded border border-slate-300 bg-white px-1.5 py-0.5 font-mono text-[0.7rem] text-slate-700 shadow-sm dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300">
Esc
</kbd>
, and flip to top, right, bottom, or left.
</p>
</header>
{/* Four fixed-direction triggers */}
<div className="mt-14 grid grid-cols-2 gap-x-6 gap-y-16 sm:grid-cols-4 sm:gap-y-14">
{DEMOS.map((d) => (
<div key={d.placement} className="flex flex-col items-center gap-3">
<Tooltip content={d.content} placement={d.placement}>
<span className="text-indigo-500 dark:text-indigo-400">{d.icon}</span>
{d.label}
</Tooltip>
<span className="inline-flex items-center gap-1 text-xs font-medium text-slate-400 dark:text-neutral-500">
<DirChevron placement={d.placement} />
{PLACEMENT_LABEL[d.placement]}
</span>
</div>
))}
</div>
{/* Interactive playground */}
<div className="mx-auto mt-16 max-w-2xl rounded-2xl border border-slate-200 bg-white/80 p-6 shadow-sm backdrop-blur sm:p-8 dark:border-neutral-800 dark:bg-neutral-900/70">
<div className="flex flex-col items-start justify-between gap-5 sm:flex-row sm:items-center">
<div>
<h3 className="text-base font-semibold">Try every side</h3>
<p className="mt-1 text-sm text-slate-600 dark:text-neutral-400">
Pick a direction, then hover or focus the trigger.
</p>
</div>
{/* Segmented radio group */}
<div
role="radiogroup"
aria-label="Tooltip direction"
className="inline-flex rounded-xl border border-slate-200 bg-slate-100 p-1 dark:border-neutral-700 dark:bg-neutral-800"
>
{PLACEMENTS.map((p, i) => {
const active = placement === p;
return (
<button
key={p}
ref={(el) => {
radioRefs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={active}
tabIndex={active ? 0 : -1}
onClick={() => setPlacement(p)}
onKeyDown={(e) => onRadioKey(e, i)}
className="relative rounded-lg px-3 py-1.5 text-xs font-semibold text-slate-600 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-neutral-300 dark:focus-visible:ring-indigo-400"
>
{active && (
<motion.span
layoutId="tipdir-seg-active"
transition={
reduce ? { duration: 0 } : { type: "spring", stiffness: 500, damping: 40 }
}
className="absolute inset-0 rounded-lg bg-white shadow-sm dark:bg-neutral-950"
/>
)}
<span className={`relative ${active ? "text-indigo-600 dark:text-indigo-300" : ""}`}>
{PLACEMENT_LABEL[p]}
</span>
</button>
);
})}
</div>
</div>
<div className="mt-8 flex min-h-[9rem] items-center justify-center rounded-xl border border-dashed border-slate-300 bg-slate-50/60 dark:border-neutral-700 dark:bg-neutral-950/40">
<Tooltip
key={placement}
placement={placement}
pinned={pinned}
content={
<>
This tooltip flips to the{" "}
<span className="font-semibold underline decoration-dotted underline-offset-2">
{PLACEMENT_LABEL[placement].toLowerCase()}
</span>{" "}
of its trigger.
</>
}
>
<DirChevron placement={placement} />
Hover or focus me
</Tooltip>
</div>
<label className="mt-6 flex cursor-pointer items-center gap-3 text-sm text-slate-600 dark:text-neutral-400">
<button
type="button"
role="switch"
aria-checked={pinned}
onClick={() => setPinned((v) => !v)}
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors focus-visible: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-indigo-400 dark:focus-visible:ring-offset-neutral-900 ${
pinned ? "bg-indigo-600 dark:bg-indigo-500" : "bg-slate-300 dark:bg-neutral-700"
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
pinned ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
Keep the tooltip pinned open
<span className="text-xs text-slate-400 dark:text-neutral-500">(useful for touch)</span>
</label>
</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

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

Click Tooltip
Originalclick-to-reveal tooltip

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.

