Web InnoventixFreeCode

Directions Tooltip

Original · free

tooltips in four directions

byWeb InnoventixReact + Tailwind
tipdirectionstooltips
Open

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.json
tip-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 quote

Similar components

Browse all →