Web InnoventixFreeCode

Rich Tooltip

Original · free

rich tooltip with title and body

byWeb InnoventixReact + Tailwind
tiprichtooltips
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-rich.json
tip-rich.tsx
"use client";

import { useEffect, useId, useRef, useState } from "react";
import type { ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Placement = "top" | "bottom" | "left" | "right";

interface RichTooltipProps {
  title: string;
  body: string;
  meta?: string;
  placement?: Placement;
  label: string;
  children: ReactNode;
  className?: string;
}

interface Metric {
  abbr: string;
  name: string;
  value: string;
  status: string;
  good: boolean;
  title: string;
  body: string;
  meta: string;
}

const panelPosition: 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 arrowPosition: Record<Placement, string> = {
  top: "left-1/2 top-full -translate-x-1/2 -translate-y-1/2",
  bottom: "left-1/2 bottom-full -translate-x-1/2 translate-y-1/2",
  left: "top-1/2 left-full -translate-y-1/2 -translate-x-1/2",
  right: "top-1/2 right-full -translate-y-1/2 translate-x-1/2",
};

const enterOffset: Record<Placement, { x?: number; y?: number }> = {
  top: { y: 4 },
  bottom: { y: -4 },
  left: { x: 4 },
  right: { x: -4 },
};

function InfoIcon() {
  return (
    <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="h-4 w-4">
      <circle cx="10" cy="10" r="7.25" stroke="currentColor" strokeWidth="1.4" />
      <path d="M10 9v4.25" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
      <circle cx="10" cy="6.4" r="0.95" fill="currentColor" />
    </svg>
  );
}

function HelpIcon() {
  return (
    <svg viewBox="0 0 20 20" fill="none" aria-hidden="true" className="h-3.5 w-3.5">
      <circle cx="10" cy="10" r="7.25" stroke="currentColor" strokeWidth="1.4" />
      <path
        d="M8.1 7.7a1.9 1.9 0 0 1 3.6.8c0 1.3-1.7 1.6-1.7 2.7"
        stroke="currentColor"
        strokeWidth="1.4"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
      <circle cx="10" cy="13.7" r="0.95" fill="currentColor" />
    </svg>
  );
}

function RichTooltip({
  title,
  body,
  meta,
  placement = "top",
  label,
  children,
  className,
}: RichTooltipProps) {
  const [open, setOpen] = useState(false);
  const tooltipId = useId();
  const timer = useRef<number | null>(null);
  const reduce = useReducedMotion();

  useEffect(() => {
    return () => {
      if (timer.current !== null) window.clearTimeout(timer.current);
    };
  }, []);

  const scheduleOpen = () => {
    if (timer.current !== null) window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setOpen(true), 120);
  };

  const close = () => {
    if (timer.current !== null) {
      window.clearTimeout(timer.current);
      timer.current = null;
    }
    setOpen(false);
  };

  const offset = enterOffset[placement];
  const hidden = reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, ...offset };
  const shown = { opacity: 1, scale: 1, x: 0, y: 0 };

  return (
    <span
      className="relative inline-flex"
      onMouseEnter={scheduleOpen}
      onMouseLeave={close}
      onKeyDown={(e) => {
        if (e.key === "Escape") close();
      }}
    >
      <button
        type="button"
        aria-label={label}
        aria-describedby={open ? tooltipId : undefined}
        aria-expanded={open}
        onFocus={() => setOpen(true)}
        onBlur={close}
        onClick={() => setOpen((v) => !v)}
        className={
          "inline-flex items-center justify-center rounded-full text-slate-400 outline-none transition-colors hover:text-indigo-600 focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-500 dark:hover:text-indigo-300 dark:focus-visible:ring-offset-slate-900 " +
          (className ?? "")
        }
      >
        {children}
      </button>

      <AnimatePresence>
        {open ? (
          <motion.span
            key={tooltipId}
            role="tooltip"
            id={tooltipId}
            initial={hidden}
            animate={shown}
            exit={hidden}
            transition={reduce ? { duration: 0 } : { duration: 0.16, ease: [0.16, 1, 0.3, 1] }}
            className={"pointer-events-none absolute z-20 block w-64 " + panelPosition[placement]}
          >
            <span
              aria-hidden="true"
              className={
                "absolute z-0 h-2.5 w-2.5 rotate-45 rounded-[2px] border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900 " +
                arrowPosition[placement]
              }
            />
            <span className="relative z-10 block 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="flex items-start gap-2.5">
                <span className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-lg bg-indigo-50 text-indigo-600 dark:bg-indigo-500/15 dark:text-indigo-300">
                  <InfoIcon />
                </span>
                <span className="block">
                  <span className="block text-sm font-semibold text-slate-900 dark:text-white">
                    {title}
                  </span>
                  <span className="mt-1 block text-[13px] leading-relaxed text-slate-600 dark:text-slate-300">
                    {body}
                  </span>
                  {meta ? (
                    <span className="mt-2.5 inline-flex items-center gap-1.5 rounded-md bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-600 dark:bg-slate-800 dark:text-slate-300">
                      <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
                      {meta}
                    </span>
                  ) : null}
                </span>
              </span>
            </span>
          </motion.span>
        ) : null}
      </AnimatePresence>
    </span>
  );
}

const pillBase =
  "inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium ring-1 ring-inset ";
const goodPill =
  pillBase +
  "bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-400/20";
const warnPill =
  pillBase +
  "bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-400/20";

const metrics: Metric[] = [
  {
    abbr: "LCP",
    name: "Largest Contentful Paint",
    value: "1.9 s",
    status: "Good",
    good: true,
    title: "Largest Contentful Paint",
    body: "Marks when the biggest image or block of text in the viewport finishes rendering — the moment the page first feels loaded to a visitor.",
    meta: "Good ≤ 2.5 s",
  },
  {
    abbr: "INP",
    name: "Interaction to Next Paint",
    value: "184 ms",
    status: "Good",
    good: true,
    title: "Interaction to Next Paint",
    body: "The delay between a tap, click or keypress and the next frame the browser paints. It samples the worst interactions across the whole visit.",
    meta: "Good ≤ 200 ms",
  },
  {
    abbr: "CLS",
    name: "Cumulative Layout Shift",
    value: "0.14",
    status: "Needs work",
    good: false,
    title: "Cumulative Layout Shift",
    body: "Adds up how far visible elements jump while the page settles. Reserve height for images, ads and embeds so content never shoves under a reader's thumb.",
    meta: "Good ≤ 0.10",
  },
];

const keyframes = `
@keyframes tiprich-pulse {
  0%, 100% { opacity: 0.55; transform: scale(1); }
  70% { opacity: 0; transform: scale(2.4); }
}
.tiprich-live::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 9999px;
  background: currentColor;
  animation: tiprich-pulse 2.4s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
@media (prefers-reduced-motion: reduce) {
  .tiprich-live::after { animation: none; opacity: 0; }
}
`;

export default function TipRich() {
  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-6 py-20 dark:bg-slate-950 sm:py-28">
      <style>{keyframes}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-x-0 top-0 h-72 bg-gradient-to-b from-indigo-100/60 to-transparent dark:from-indigo-500/10"
      />

      <div className="relative mx-auto max-w-xl">
        <header className="text-center">
          <p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
            Performance
          </p>
          <h2 className="mt-3 text-2xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-3xl">
            Read the metric, not the manual
          </h2>
          <p className="mx-auto mt-3 max-w-md text-sm leading-relaxed text-slate-600 dark:text-slate-300">
            Every score carries its own explainer. Point at it, tab to it, or tap it — the
            definition and the threshold to beat come along for the ride.
          </p>
        </header>

        <div className="mt-8 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-6">
          <div className="flex items-start justify-between gap-4">
            <div className="flex items-center gap-1.5">
              <h3 className="text-base font-semibold text-slate-900 dark:text-white">
                Core Web Vitals
              </h3>
              <RichTooltip
                placement="bottom"
                label="About this report"
                title="About this report"
                body="Field data from the Chrome UX Report, refreshed hourly. Each score reflects the 75th percentile of real visits over the trailing 28 days."
                meta="p75 · mobile"
                className="h-6 w-6"
              >
                <HelpIcon />
              </RichTooltip>
            </div>
            <span className="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
              <span className="relative inline-flex h-2 w-2 text-emerald-500">
                <span className="tiprich-live absolute inset-0 inline-flex rounded-full" />
                <span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
              </span>
              Updated 2h ago
            </span>
          </div>

          <ul role="list" className="mt-2 divide-y divide-slate-100 dark:divide-slate-800">
            {metrics.map((m) => (
              <li key={m.abbr} className="flex items-center gap-4 py-4">
                <span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-slate-100 text-sm font-bold tracking-tight text-slate-700 dark:bg-slate-800 dark:text-slate-200">
                  {m.abbr}
                </span>
                <span className="min-w-0 flex-1">
                  <span className="block truncate text-sm font-medium text-slate-900 dark:text-white">
                    {m.name}
                  </span>
                  <span className="block text-xs tabular-nums text-slate-500 dark:text-slate-400">
                    {m.value}
                  </span>
                </span>
                <span className={m.good ? goodPill : warnPill}>{m.status}</span>
                <RichTooltip
                  placement="left"
                  label={"What " + m.name + " measures"}
                  title={m.title}
                  body={m.body}
                  meta={m.meta}
                  className="h-8 w-8"
                >
                  <InfoIcon />
                </RichTooltip>
              </li>
            ))}
          </ul>

          <div className="mt-3 flex flex-wrap items-center gap-x-1.5 gap-y-1 rounded-lg bg-slate-50 px-3 py-2.5 text-xs text-slate-500 dark:bg-slate-800/50 dark:text-slate-400">
            <span>Keep all three green to unlock the</span>
            <RichTooltip
              placement="top"
              label="What the performance badge is"
              title="Performance badge"
              body="Hold every metric in the green for seven days straight and your listing earns a fast-site badge in search and directory results."
              meta="7-day streak"
              className="!rounded font-medium !text-indigo-600 underline decoration-dotted underline-offset-2 hover:!text-indigo-700 dark:!text-indigo-300 dark:hover:!text-indigo-200"
            >
              performance badge
            </RichTooltip>
            <span>.</span>
          </div>
        </div>

        <p className="mt-6 text-center text-xs text-slate-500 dark:text-slate-400">
          Hover, or press{" "}
          <kbd className="rounded border border-slate-300 bg-white px-1.5 py-0.5 font-mono text-[11px] text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">
            Tab
          </kbd>{" "}
          to focus a control.{" "}
          <kbd className="rounded border border-slate-300 bg-white px-1.5 py-0.5 font-mono text-[11px] text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">
            Esc
          </kbd>{" "}
          dismisses it.
        </p>
      </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 →