Web InnoventixFreeCode

Click Tooltip

Original · free

click-to-reveal tooltip

byWeb InnoventixReact + Tailwind
tipclicktooltips
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-click.json
tip-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 quote

Similar components

Browse all →