Web InnoventixFreeCode

Color Tooltip

Original · free

coloured semantic tooltips

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

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

type Tone = "info" | "success" | "warning" | "danger" | "neutral" | "accent";
type Placement = "top" | "bottom";

const cn = (...classes: Array<string | false | null | undefined>): string =>
  classes.filter(Boolean).join(" ");

interface ToneCfg {
  label: string;
  renderIcon: (className: string) => ReactNode;
  bubble: string;
  arrowBg: string;
  chip: string;
  pill: string;
  dot: string;
  iconBtn: string;
}

const strokeIcon = (className: string, children: ReactNode): ReactNode => (
  <svg
    className={className}
    viewBox="0 0 24 24"
    fill="none"
    stroke="currentColor"
    strokeWidth={1.8}
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden="true"
  >
    {children}
  </svg>
);

const TONE: Record<Tone, ToneCfg> = {
  info: {
    label: "Info",
    renderIcon: (c) =>
      strokeIcon(
        c,
        <>
          <circle cx="12" cy="12" r="9" />
          <path d="M12 16v-4" />
          <path d="M12 8h.01" />
        </>,
      ),
    bubble:
      "bg-sky-600 text-white ring-sky-400/40 dark:bg-sky-500 dark:text-sky-50",
    arrowBg: "bg-sky-600 dark:bg-sky-500",
    chip: "border-sky-200 bg-sky-50 text-sky-700 hover:bg-sky-100 focus-visible:ring-sky-500 dark:border-sky-400/25 dark:bg-sky-400/10 dark:text-sky-300 dark:hover:bg-sky-400/20",
    pill: "bg-sky-50 text-sky-700 ring-sky-600/20 dark:bg-sky-400/10 dark:text-sky-300 dark:ring-sky-400/30",
    dot: "bg-sky-500",
    iconBtn:
      "text-sky-500 hover:bg-sky-50 hover:text-sky-600 focus-visible:ring-sky-500 dark:text-sky-400 dark:hover:bg-sky-400/10",
  },
  success: {
    label: "Success",
    renderIcon: (c) =>
      strokeIcon(
        c,
        <>
          <circle cx="12" cy="12" r="9" />
          <path d="M8.4 12.4l2.4 2.4 4.8-5.2" />
        </>,
      ),
    bubble:
      "bg-emerald-600 text-white ring-emerald-400/40 dark:bg-emerald-500 dark:text-emerald-50",
    arrowBg: "bg-emerald-600 dark:bg-emerald-500",
    chip: "border-emerald-200 bg-emerald-50 text-emerald-700 hover:bg-emerald-100 focus-visible:ring-emerald-500 dark:border-emerald-400/25 dark:bg-emerald-400/10 dark:text-emerald-300 dark:hover:bg-emerald-400/20",
    pill: "bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-400/10 dark:text-emerald-300 dark:ring-emerald-400/30",
    dot: "bg-emerald-500",
    iconBtn:
      "text-emerald-500 hover:bg-emerald-50 hover:text-emerald-600 focus-visible:ring-emerald-500 dark:text-emerald-400 dark:hover:bg-emerald-400/10",
  },
  warning: {
    label: "Warning",
    renderIcon: (c) =>
      strokeIcon(
        c,
        <>
          <path d="M12 4 3 19h18z" />
          <path d="M12 10v4" />
          <path d="M12 16.5h.01" />
        </>,
      ),
    bubble:
      "bg-amber-400 text-amber-950 ring-amber-500/40 dark:bg-amber-400 dark:text-amber-950",
    arrowBg: "bg-amber-400",
    chip: "border-amber-200 bg-amber-50 text-amber-800 hover:bg-amber-100 focus-visible:ring-amber-500 dark:border-amber-400/25 dark:bg-amber-400/10 dark:text-amber-300 dark:hover:bg-amber-400/20",
    pill: "bg-amber-50 text-amber-800 ring-amber-600/25 dark:bg-amber-400/10 dark:text-amber-300 dark:ring-amber-400/30",
    dot: "bg-amber-500",
    iconBtn:
      "text-amber-500 hover:bg-amber-50 hover:text-amber-600 focus-visible:ring-amber-500 dark:text-amber-400 dark:hover:bg-amber-400/10",
  },
  danger: {
    label: "Danger",
    renderIcon: (c) =>
      strokeIcon(
        c,
        <>
          <path d="M8.6 2.6h6.8L21.4 8.6v6.8L15.4 21.4H8.6L2.6 15.4V8.6z" />
          <path d="M12 8v4" />
          <path d="M12 16h.01" />
        </>,
      ),
    bubble:
      "bg-rose-600 text-white ring-rose-400/40 dark:bg-rose-500 dark:text-rose-50",
    arrowBg: "bg-rose-600 dark:bg-rose-500",
    chip: "border-rose-200 bg-rose-50 text-rose-700 hover:bg-rose-100 focus-visible:ring-rose-500 dark:border-rose-400/25 dark:bg-rose-400/10 dark:text-rose-300 dark:hover:bg-rose-400/20",
    pill: "bg-rose-50 text-rose-700 ring-rose-600/20 dark:bg-rose-400/10 dark:text-rose-300 dark:ring-rose-400/30",
    dot: "bg-rose-500",
    iconBtn:
      "text-rose-500 hover:bg-rose-50 hover:text-rose-600 focus-visible:ring-rose-500 dark:text-rose-400 dark:hover:bg-rose-400/10",
  },
  neutral: {
    label: "Neutral",
    renderIcon: (c) =>
      strokeIcon(
        c,
        <>
          <circle cx="12" cy="12" r="9" />
          <path d="M12 7.5V12l3 1.8" />
        </>,
      ),
    bubble:
      "bg-slate-800 text-slate-50 ring-white/10 dark:bg-slate-700 dark:text-slate-50",
    arrowBg: "bg-slate-800 dark:bg-slate-700",
    chip: "border-slate-200 bg-slate-50 text-slate-700 hover:bg-slate-100 focus-visible:ring-slate-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700",
    pill: "bg-slate-100 text-slate-700 ring-slate-500/20 dark:bg-slate-800 dark:text-slate-300 dark:ring-slate-600/40",
    dot: "bg-slate-400",
    iconBtn:
      "text-slate-500 hover:bg-slate-100 hover:text-slate-700 focus-visible:ring-slate-500 dark:text-slate-400 dark:hover:bg-slate-800",
  },
  accent: {
    label: "Feature",
    renderIcon: (c) => (
      <svg className={c} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
        <path d="M12 3l2 6 6 2-6 2-2 6-2-6-6-2 6-2z" />
      </svg>
    ),
    bubble:
      "bg-violet-600 text-white ring-violet-400/40 dark:bg-violet-500 dark:text-violet-50",
    arrowBg: "bg-violet-600 dark:bg-violet-500",
    chip: "border-violet-200 bg-violet-50 text-violet-700 hover:bg-violet-100 focus-visible:ring-violet-500 dark:border-violet-400/25 dark:bg-violet-400/10 dark:text-violet-300 dark:hover:bg-violet-400/20",
    pill: "bg-violet-50 text-violet-700 ring-violet-600/20 dark:bg-violet-400/10 dark:text-violet-300 dark:ring-violet-400/30",
    dot: "bg-violet-500",
    iconBtn:
      "text-violet-500 hover:bg-violet-50 hover:text-violet-600 focus-visible:ring-violet-500 dark:text-violet-400 dark:hover:bg-violet-400/10",
  },
};

const helpIcon = (className: string): ReactNode =>
  strokeIcon(
    className,
    <>
      <circle cx="12" cy="12" r="9" />
      <path d="M9.6 9.4a2.4 2.4 0 1 1 3.6 2.1c-.8.5-1.2 1-1.2 1.9" />
      <path d="M12 17h.01" />
    </>,
  );

interface TipProps {
  tone: Tone;
  placement: Placement;
  tip: ReactNode;
  children: ReactNode;
  triggerClassName?: string;
  ariaLabel?: string;
}

function Tip({ tone, placement, tip, children, triggerClassName, ariaLabel }: TipProps) {
  const [open, setOpen] = useState(false);
  const reduce = useReducedMotion();
  const tipId = useId();
  const cfg = TONE[tone];

  const bubblePos = placement === "top" ? "bottom-full mb-2.5" : "top-full mt-2.5";
  const arrowPos =
    placement === "top" ? "top-full -translate-y-1/2" : "bottom-full translate-y-1/2";
  const offsetY = placement === "top" ? 4 : -4;

  return (
    <span className="relative inline-flex">
      <button
        type="button"
        aria-label={ariaLabel}
        aria-describedby={open ? tipId : undefined}
        onPointerEnter={() => setOpen(true)}
        onPointerLeave={() => setOpen(false)}
        onFocus={() => setOpen(true)}
        onBlur={() => setOpen(false)}
        onKeyDown={(e) => {
          if (e.key === "Escape") setOpen(false);
        }}
        className={triggerClassName}
      >
        {children}
      </button>

      <AnimatePresence>
        {open && (
          <motion.span
            role="tooltip"
            id={tipId}
            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.94, y: offsetY }}
            animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }}
            exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.94, y: offsetY }}
            transition={{ duration: reduce ? 0 : 0.16, ease: [0.16, 1, 0.3, 1] }}
            className={cn(
              "pointer-events-none absolute left-1/2 z-30 w-max max-w-[15rem] -translate-x-1/2 rounded-xl px-3 py-2 text-left text-xs font-medium leading-relaxed shadow-lg shadow-slate-900/15 ring-1 ring-inset dark:shadow-black/40",
              bubblePos,
              cfg.bubble,
            )}
          >
            {tip}
            <span
              aria-hidden="true"
              className={cn(
                "absolute left-1/2 h-2.5 w-2.5 -translate-x-1/2 rotate-45 rounded-[2px]",
                arrowPos,
                cfg.arrowBg,
              )}
            />
          </motion.span>
        )}
      </AnimatePresence>
    </span>
  );
}

interface Showcase {
  tone: Tone;
  tip: string;
}

const SHOWCASE: Showcase[] = [
  { tone: "info", tip: "Builds run on Node 20 LTS. Pin your engines field so CI matches your local runtime." },
  { tone: "success", tip: "1,284 tests passed in 42s. Coverage held steady at 91% with no flaky retries." },
  { tone: "warning", tip: "Main chunk grew 38 KB versus main. Still under budget, but review before you release." },
  { tone: "danger", tip: "2 high-severity CVEs in transitive dependencies. This blocks the production promote." },
  { tone: "neutral", tip: "Last deployed 6 days ago by Priya from release/1.9 at 14:07 UTC." },
  { tone: "accent", tip: "New: preview environments now deploy automatically on every pull request." },
];

interface StatusRow {
  name: string;
  tone: Tone;
  status: string;
  tip: string;
}

const STATUS: StatusRow[] = [
  {
    name: "Build & compile",
    tone: "success",
    status: "Passing",
    tip: "Compiled and cached in 1m 12s. Artifact signed and pushed to the registry.",
  },
  {
    name: "Test suite",
    tone: "success",
    status: "Passing",
    tip: "1,284 unit and integration tests green. No flaky retries in this run.",
  },
  {
    name: "Dependency scan",
    tone: "danger",
    status: "Blocked",
    tip: "2 high-severity CVEs found. Promote is blocked until they are patched or explicitly waived.",
  },
  {
    name: "Bundle budget",
    tone: "warning",
    status: "Review",
    tip: "Main chunk is 38 KB over the soft budget. It ships, but the trend is heading the wrong way.",
  },
  {
    name: "Accessibility",
    tone: "info",
    status: "Notes",
    tip: "3 minor colour-contrast notes on the marketing footer. Non-blocking for this release.",
  },
  {
    name: "Preview environment",
    tone: "accent",
    status: "Ready",
    tip: "Ephemeral preview is live at pr-482.preview.app and stays up for 72 hours.",
  },
];

const PLACEMENTS: { value: Placement; label: string; chevron: ReactNode }[] = [
  {
    value: "top",
    label: "Top",
    chevron: strokeIcon("h-3.5 w-3.5", <path d="M6 15l6-6 6 6" />),
  },
  {
    value: "bottom",
    label: "Bottom",
    chevron: strokeIcon("h-3.5 w-3.5", <path d="M6 9l6 6 6-6" />),
  },
];

export default function TipColor() {
  const [placement, setPlacement] = useState<Placement>("top");

  const chipBase =
    "inline-flex items-center gap-2 rounded-full border px-3.5 py-2 text-sm font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900";
  const iconBtnBase =
    "inline-grid h-7 w-7 place-items-center rounded-full transition focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900";
  const pillBase =
    "inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-semibold ring-1 ring-inset";

  return (
    <section className="relative w-full bg-slate-50 px-4 py-16 sm:px-6 sm:py-20 dark:bg-slate-950">
      <style>{`
        @keyframes tipcolor-pulse {
          0%, 100% { opacity: 1; transform: scale(1); }
          50% { opacity: 0.45; transform: scale(0.62); }
        }
        @keyframes tipcolor-ring {
          0% { opacity: 0.55; transform: scale(1); }
          100% { opacity: 0; transform: scale(2.4); }
        }
        .tipcolor-pulse-dot { animation: tipcolor-pulse 2.4s ease-in-out infinite; }
        .tipcolor-ring { animation: tipcolor-ring 2.4s ease-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .tipcolor-pulse-dot, .tipcolor-ring { animation: none !important; }
        }
      `}</style>

      <div className="mx-auto w-full max-w-3xl">
        <div className="relative rounded-3xl border border-slate-200 bg-white p-6 shadow-xl shadow-slate-900/5 sm:p-8 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/30">
          {/* Header */}
          <div className="flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between">
            <div>
              <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-medium text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">
                <span className="relative flex h-2.5 w-2.5">
                  <span className="tipcolor-ring absolute inset-0 rounded-full bg-emerald-500" />
                  <span className="tipcolor-pulse-dot relative h-2.5 w-2.5 rounded-full bg-emerald-500" />
                </span>
                Live pipeline · checkout-service
              </span>
              <h2 className="mt-3 text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-white">
                Semantic tooltips
              </h2>
              <p className="mt-2 max-w-md text-sm leading-relaxed text-slate-600 dark:text-slate-400">
                Colour is the fastest label. Each tooltip tints itself to the meaning it
                carries — hover or focus any trigger to read the detail.
              </p>
            </div>

            {/* Placement control */}
            <fieldset className="shrink-0">
              <legend className="sr-only">Tooltip placement</legend>
              <div className="inline-flex rounded-full border border-slate-200 bg-slate-100 p-1 dark:border-slate-700 dark:bg-slate-800">
                {PLACEMENTS.map((p) => (
                  <label key={p.value} className="relative cursor-pointer">
                    <input
                      type="radio"
                      name="tipcolor-placement"
                      value={p.value}
                      checked={placement === p.value}
                      onChange={() => setPlacement(p.value)}
                      className="peer sr-only"
                    />
                    <span className="flex select-none items-center gap-1.5 rounded-full px-3.5 py-1.5 text-sm font-medium text-slate-600 transition peer-checked:bg-white peer-checked:text-slate-900 peer-checked:shadow-sm peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 dark:text-slate-300 dark:peer-checked:bg-slate-950 dark:peer-checked:text-white">
                      {p.chevron}
                      {p.label}
                    </span>
                  </label>
                ))}
              </div>
            </fieldset>
          </div>

          {/* Tone showcase */}
          <div className="mt-8">
            <div className="mb-3 flex items-baseline justify-between gap-3">
              <h3 className="text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">
                Tones
              </h3>
              <p className="text-xs text-slate-400 dark:text-slate-500">
                Hover, or press Tab to focus
              </p>
            </div>
            <div className="flex flex-wrap gap-2.5">
              {SHOWCASE.map((s) => {
                const cfg = TONE[s.tone];
                return (
                  <Tip
                    key={s.tone}
                    tone={s.tone}
                    placement={placement}
                    tip={s.tip}
                    triggerClassName={cn(chipBase, cfg.chip)}
                  >
                    {cfg.renderIcon("h-4 w-4")}
                    {cfg.label}
                  </Tip>
                );
              })}
            </div>
          </div>

          {/* Divider */}
          <div className="my-8 h-px w-full bg-slate-200 dark:bg-slate-800" />

          {/* Status panel */}
          <div>
            <div className="mb-4 flex items-baseline justify-between gap-3">
              <h3 className="text-sm font-semibold text-slate-900 dark:text-white">
                Release readiness
              </h3>
              <span className="text-xs font-medium text-slate-400 dark:text-slate-500">
                Build #4821 · release/1.9
              </span>
            </div>

            <ul className="divide-y divide-slate-100 rounded-2xl border border-slate-200 dark:divide-slate-800 dark:border-slate-800">
              {STATUS.map((row) => {
                const cfg = TONE[row.tone];
                return (
                  <li
                    key={row.name}
                    className="flex items-center justify-between gap-3 px-4 py-3.5"
                  >
                    <div className="flex min-w-0 items-center gap-3">
                      <span className={cn("h-2 w-2 shrink-0 rounded-full", cfg.dot)} />
                      <span className="truncate text-sm font-medium text-slate-800 dark:text-slate-200">
                        {row.name}
                      </span>
                    </div>
                    <div className="flex shrink-0 items-center gap-2">
                      <span className={cn(pillBase, cfg.pill)}>
                        {cfg.renderIcon("h-3.5 w-3.5")}
                        {row.status}
                      </span>
                      <Tip
                        tone={row.tone}
                        placement={placement}
                        tip={row.tip}
                        ariaLabel={`Details about ${row.name}`}
                        triggerClassName={cn(iconBtnBase, cfg.iconBtn)}
                      >
                        {helpIcon("h-4 w-4")}
                      </Tip>
                    </div>
                  </li>
                );
              })}
            </ul>
          </div>

          {/* Footer hint */}
          <p className="mt-6 text-xs leading-relaxed text-slate-500 dark:text-slate-400">
            Every tooltip opens on hover and on keyboard focus. Tab moves between triggers,
            and{" "}
            <kbd className="rounded border border-slate-300 bg-slate-100 px-1.5 py-0.5 font-sans text-[0.7rem] font-semibold text-slate-600 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-300">
              Esc
            </kbd>{" "}
            dismisses the one in focus.
          </p>
        </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 →