FewTone UI
ComponentsDocs
  • Home

Getting started

  • Introduction
  • Installation
  • MCP Server
  • Smooth UI Skill
  • Roadmap

Components46

  • Accordion
  • Alert Dialog
  • Avatar
  • Badge
  • Breadcrumb
  • Button
  • Calendar
  • Card
  • Checkbox
  • Combobox
  • Command
  • Context Menu
  • Copy Button
  • Date Picker
  • Dialog
  • Dropdown Menu
  • Hover Card
  • Input
  • Input OTP
  • Loading Button
  • Marquee
  • Navigation Menu
  • Number Input
  • Number Ticker
  • Pagination
  • Popover
  • Progress
  • Radio Group
  • Scroll Area
  • Select
  • Sheet
  • Sidebar
  • Skeleton
  • Slider
  • Spinner
  • Stepper
  • Switch
  • Switcher
  • Table
  • Tabs
  • Text Effects
  • Textarea
  • Theme Toggle
  • Toast
  • Toggle Group
  • Tooltip
© 2026 FewTone · MIT licenceGitHub
  1. Components
  2. Hover Card

Hover Card

pnpm dlx shadcn@latest add …/r/hover-card.json

An accessible React hover card for Next.js and Tailwind CSS: a preview card for a link, like a profile on an @mention, built on Radix Hover Card.

Reviewed by @maya, @theo and @ines.

import {
  HoverCard,
  HoverCardContent,
  HoverCardTrigger,
} from "@/components/ui/hover-card"

export function Mention() {
  return (
    <HoverCard>
      <HoverCardTrigger href="/people/maya">@maya</HoverCardTrigger>
      <HoverCardContent>
        <p className="text-sm font-medium">Maya Chen</p>
        <p className="text-sm text-muted-foreground">Design engineer.</p>
      </HoverCardContent>
    </HoverCard>
  )
}
profile.tsx
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { CalendarIcon } from "lucide-react";
import { Fragment } from "react";

type Person = { handle: string; name: string; initials: string; bio: string; joined: string };

const people: Person[] = [
  {
    handle: "maya",
    name: "Maya Chen",
    initials: "MC",
    bio: "Design engineer. Motion, type and interfaces that work for everyone.",
    joined: "Joined March 2024",
  },
  {
    handle: "theo",
    name: "Theo Laurent",
    initials: "TL",
    bio: "Looks after the docs and the release notes.",
    joined: "Joined June 2024",
  },
  {
    handle: "ines",
    name: "Inés Romero",
    initials: "IR",
    bio: "Front-end developer at a small studio. Writes about CSS.",
    joined: "Joined January 2025",
  },
];

function Mention({ person }: { person: Person }) {
  return (
    <HoverCard>
      <HoverCardTrigger
        href={`https://example.com/${person.handle}`}
        className="font-medium text-foreground underline decoration-border underline-offset-4 transition-colors duration-(--duration-fast) ease-out hover:decoration-current outline-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)"
      >
        @{person.handle}
      </HoverCardTrigger>
      <HoverCardContent>
        <div className="flex gap-3">
          {/* Initials stand in for a photo */}
          <span
            aria-hidden
            className="flex size-10 shrink-0 items-center justify-center rounded-full bg-muted text-sm font-medium"
          >
            {person.initials}
          </span>
          <div className="grid gap-2">
            <div>
              <p className="text-sm font-medium">{person.name}</p>
              <p className="text-sm text-muted-foreground">@{person.handle}</p>
            </div>
            <p className="text-sm">{person.bio}</p>
            <p className="flex items-center gap-1.5 text-xs text-muted-foreground">
              <CalendarIcon aria-hidden className="size-(--size-icon-sm)" />
              {person.joined}
            </p>
          </div>
        </div>
      </HoverCardContent>
    </HoverCard>
  );
}

/** Rest on a name to see who it is, then move along: the next card opens at once */
export default function HoverCardProfile() {
  return (
    <p className="max-w-sm text-center text-sm text-muted-foreground">
      Reviewed by{" "}
      {people.map((person, index) => (
        <Fragment key={person.handle}>
          {index === 0 ? null : index === people.length - 1 ? " and " : ", "}
          <Mention person={person} />
        </Fragment>
      ))}
      .
    </p>
  );
}

Installation

  1. 1

    Install the dependencies.

    Package manager
    $ pnpm add motion radix-ui
  2. 2

    Add the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.

    app/globals.css
    :root {
      --size-control-sm: 2rem;
      --size-control-md: 2.25rem;
      --size-control-lg: 2.5rem;
      --size-icon-sm: 0.875rem;
      --size-icon-md: 1rem;
      --size-icon-lg: 1.25rem;
      --size-roll-fade: 0.25em;
      --size-roll-fade-x: 0.5em;
      --duration-instant: 80ms;
      --duration-fast: 150ms;
      --duration-base: 220ms;
      --duration-slow: 350ms;
      --duration-slower: 500ms;
      --z-overlay: 1200;
      --z-modal: 1300;
      --z-popover: 1400;
      --z-toast: 1500;
      --z-tooltip: 1600;
      --focus-ring-width: 2px;
      --focus-ring-offset: 2px;
      --opacity-disabled: 0.5;
      --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1);
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent);
    }
    
    .dark {
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent);
    }
    
    @theme inline {
      --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both;
      --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both;
      --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1);
      --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both;
      --color-destructive-foreground: var(--destructive-foreground);
      --color-overlay: var(--overlay);
      @keyframes popover-in {
        from {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
        to {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
      }
      @keyframes popover-out {
        from {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
        to {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
      }
      @keyframes collapsible-open {
        from {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
        to {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
      }
      @keyframes collapsible-close {
        from {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
        to {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
      }
    }
    
    @media (prefers-reduced-motion: reduce) {
      :root {
        --duration-instant: 1ms;
        --duration-fast: 1ms;
        --duration-base: 1ms;
        --duration-slow: 1ms;
        --duration-slower: 1ms;
      }
    }
  3. 3

    Add the motion presets.

    lib/motion.ts
    import type { Transition } from "motion/react";
    
    /*
     * Motion tokens for JavaScript animations (motion/react).
     * Durations and easings mirror the CSS tokens in src/styles/tokens.css;
     * `pnpm check:tokens` fails if the two drift apart.
     */
    
    /** Durations in seconds. Mirrors --duration-* in tokens.css. */
    export const duration = {
      instant: 0.08,
      fast: 0.15,
      base: 0.22,
      slow: 0.35,
      slower: 0.5,
    } as const;
    
    /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */
    export const ease = {
      out: [0.22, 1, 0.36, 1],
      in: [0.55, 0, 1, 0.45],
      inOut: [0.65, 0, 0.35, 1],
    } as const;
    
    /**
     * Named springs: the library's motion voice.
     * - snappy: quick, almost no overshoot. Toggles, tabs, small controls.
     * - smooth: slow and settled. Panels, sidebars, page sections.
     * - bouncy: playful overshoot. Rare, for moments of delight.
     */
    export const spring = {
      snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 },
      smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 },
      bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 },
    } as const satisfies Record<string, Transition>;
    
    export type SpringName = keyof typeof spring;
    
    /**
     * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it
     * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills,
     * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only.
     * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`).
     */
    export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const;
    
    /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */
    export const swap = {
      initial: { opacity: 0, filter: blur.hidden, y: 6 },
      animate: { opacity: 1, filter: blur.shown, y: 0 },
      exit: { opacity: 0, filter: blur.hidden, y: -6 },
    } as const;
    
    /**
     * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits'
     * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in
     * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in
     * components/number-ticker/number-ticker.tsx).
     */
    export const flow = {
      transform: {
        duration: 900,
        easing:
          "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)",
      },
      opacity: { duration: 450, easing: "ease-out" },
    } as const satisfies Record<string, EffectTiming>;
  4. 4

    Copy the source into your project.

    components/ui/hover-card.tsx
    "use client";
    
    import { blur, duration, ease } from "@/lib/motion";
    import { cn } from "@/lib/utils";
    import { type AnimationPlaybackControls, animate, useReducedMotion } from "motion/react";
    import { HoverCard as HoverCardPrimitive } from "radix-ui";
    import {
      type ComponentProps,
      createContext,
      type RefObject,
      useCallback,
      useContext,
      useEffect,
      useLayoutEffect,
      useRef,
      useState,
    } from "react";
    
    /*
     * Hover Card, built on Radix Hover Card: a preview of what a link points to (a profile, a page) for sighted
     * users. It opens when the pointer rests on the link or the link takes keyboard focus, stays open while the
     * pointer moves onto it, and closes on Escape. Screen readers don't get the card, so the link has to make
     * sense on its own.
     * - The surface and motion are Popover's: it opens out of the link through the blur, from Radix's
     *   transform origin, and closes back into it.
     * - It waits 300ms before opening, as Tooltip does (Radix waits 700ms, which made the first card feel slow).
     * - From one link to the next (names in a comment, people in a list): once a card has been open, the next
     *   opens at once and takes its place, with no delay. The two read as one card that moves: the new one
     *   starts where the last one was, at its size, and glides to its own place and size (Navigation Menu's
     *   panel: transform for the place, width and height for the size, duration.base + ease.out), while its
     *   content slides 12px in from the side it moved towards, through the blur. The last one goes in the same
     *   frame. With reduced motion it simply appears in its place.
     * - Nothing animates on first paint.
     */
    
    /** How far the content slides in as the card moves to the next link, in px (Navigation Menu's) */
    const SLIDE = 12;
    /** The inline styles the glide leaves on a card and its content, cleared when it's done */
    const LEFTOVERS = ["width", "height", "overflow", "transform"];
    const CONTENT_LEFTOVERS = ["opacity", "transform", "filter"];
    
    type Shown = { hide: () => void; measure: () => DOMRect | null };
    
    /** Page-wide: the card on show (open, or the last one that closed), whether it's open, and when it closed */
    const shown: { card: Shown | null; open: boolean; closedAt: number } = {
      card: null,
      open: false,
      closedAt: Number.NEGATIVE_INFINITY,
    };
    
    /** One card's way of coming and going, read by its trigger and content */
    const CardContext = createContext<{
      following: boolean;
      hidden: boolean;
      openAtOnce: () => void;
      /** The card's surface on the page, so the next card can start from where it is */
      surface: RefObject<HTMLDivElement | null>;
      /** Where the card this one replaced was, taken by the content as it mounts */
      from: RefObject<DOMRect | null>;
    } | null>(null);
    
    export type HoverCardProps = ComponentProps<typeof HoverCardPrimitive.Root> & {
      /** Milliseconds after a card closes during which the next one opens at once, without the delay */
      skipDelayDuration?: number;
    };
    
    function HoverCard({
      open: openProp,
      defaultOpen = false,
      onOpenChange,
      openDelay = 300,
      closeDelay = 300,
      skipDelayDuration = 300,
      ...props
    }: HoverCardProps) {
      const [uncontrolled, setUncontrolled] = useState(defaultOpen);
      // Open from the start counts as following: it's simply there, with nothing animating on first paint
      const [following, setFollowing] = useState(defaultOpen);
      const [hidden, setHidden] = useState(false);
      const open = openProp ?? uncontrolled;
      // This card in the page-wide record. Its methods are refreshed after every render, so they see the latest state.
      const card = useRef<Shown>({ hide: () => {}, measure: () => null });
      const surface = useRef<HTMLDivElement | null>(null);
      const from = useRef<DOMRect | null>(null);
    
      const setOpen = (next: boolean, follows = false) => {
        // Radix's timers can report a state it's already in, for example after the card opened at once
        if (next === open) return;
        if (next) {
          // One card at a time: the one on show gives way in the same frame, and this one starts from where it was
          from.current = null;
          if (shown.card && shown.card !== card.current) {
            if (follows && shown.open) from.current = shown.card.measure();
            shown.card.hide();
          }
          shown.card = card.current;
          shown.open = true;
          setFollowing(follows);
          setHidden(false);
        } else if (shown.card === card.current) {
          shown.open = false;
          shown.closedAt = performance.now();
        }
        if (openProp === undefined) setUncontrolled(next);
        onOpenChange?.(next);
      };
    
      useLayoutEffect(() => {
        card.current.hide = () => {
          setHidden(true);
          setOpen(false);
        };
        card.current.measure = () =>
          surface.current?.isConnected ? surface.current.getBoundingClientRect() : null;
      });
    
      useEffect(() => {
        const self = card.current;
        return () => {
          if (shown.card !== self) return;
          shown.card = null;
          shown.open = false;
        };
      }, []);
    
      /** Called as the pointer or focus reaches the link: skip the delay if another card was just on show */
      const openAtOnce = () => {
        if (open) return;
        if (shown.open || performance.now() - shown.closedAt < skipDelayDuration) setOpen(true, true);
      };
    
      return (
        <CardContext.Provider value={{ following, hidden, openAtOnce, surface, from }}>
          <HoverCardPrimitive.Root
            data-slot="hover-card"
            open={open}
            onOpenChange={(next) => setOpen(next)}
            openDelay={openDelay}
            closeDelay={closeDelay}
            {...props}
          />
        </CardContext.Provider>
      );
    }
    
    function HoverCardTrigger({
      onPointerEnter,
      onFocus,
      ...props
    }: ComponentProps<typeof HoverCardPrimitive.Trigger>) {
      const card = useContext(CardContext);
    
      return (
        <HoverCardPrimitive.Trigger
          data-slot="hover-card-trigger"
          // These run before Radix's own, which still start its delay; opening at once makes that a no-op
          onPointerEnter={(event) => {
            onPointerEnter?.(event);
            if (!event.defaultPrevented && event.pointerType !== "touch") card?.openAtOnce();
          }}
          onFocus={(event) => {
            onFocus?.(event);
            if (!event.defaultPrevented) card?.openAtOnce();
          }}
          {...props}
        />
      );
    }
    
    type Edge = "start" | "end" | "center";
    
    /** A point along one axis of a box: its start, its end or its centre */
    const along = (start: number, size: number, edge: Edge) =>
      edge === "start" ? start : edge === "end" ? start + size : start + size / 2;
    
    /** The edge Radix holds still on an axis while the card's size changes: the one by the link, or the aligned one */
    function edgeOf(side: string | undefined, align: string | undefined, axis: "x" | "y"): Edge {
      const [before, after] = axis === "x" ? ["left", "right"] : ["top", "bottom"];
      if (side === before) return "end";
      if (side === after) return "start";
      return align === "start" ? "start" : align === "end" ? "end" : "center";
    }
    
    /**
     * Glides a card that took another's place from where that one was: its place (transform) and its size (width
     * and height), while its content comes in through the blur from the side it moved towards. Returns a stop.
     */
    function glideFrom(node: HTMLDivElement, from: DOMRect) {
      const to = node.getBoundingClientRect();
      if (to.width === 0 || to.height === 0) return () => {};
      const { side, align } = node.dataset;
      const xEdge = edgeOf(side, align, "x");
      const yEdge = edgeOf(side, align, "y");
      const dx = along(from.left, from.width, xEdge) - along(to.left, to.width, xEdge);
      const dy = along(from.top, from.height, yEdge) - along(to.top, to.height, yEdge);
      const timing = { duration: duration.base, ease: ease.out };
      const content = node.firstElementChild instanceof HTMLElement ? node.firstElementChild : null;
    
      // Clipped only while the size moves
      node.style.overflow = "hidden";
      const runs: AnimationPlaybackControls[] = [
        animate(
          node,
          { x: [dx, 0], y: [dy, 0], width: [from.width, to.width], height: [from.height, to.height] },
          timing,
        ),
      ];
      if (content) {
        const horizontal = Math.abs(dx) >= Math.abs(dy);
        // Moving right, the content comes in from the right (as Navigation Menu's)
        const towards = Math.sign(horizontal ? -dx : -dy);
        const slide = horizontal ? { x: [towards * SLIDE, 0] } : { y: [towards * SLIDE, 0] };
        runs.push(animate(content, { opacity: [0, 1], filter: [blur.hidden, blur.shown], ...slide }, timing));
      }
    
      // A filter or size left behind would break the next glide and its closing keyframes
      const clear = () => {
        for (const property of LEFTOVERS) node.style.removeProperty(property);
        if (content) for (const property of CONTENT_LEFTOVERS) content.style.removeProperty(property);
      };
      Promise.all(runs).then(clear);
      return () => {
        for (const run of runs) run.stop();
        clear();
      };
    }
    
    function HoverCardContent({
      className,
      align = "center",
      sideOffset = 4,
      children,
      ref,
      ...props
    }: ComponentProps<typeof HoverCardPrimitive.Content>) {
      const card = useContext(CardContext);
      const reduce = useReducedMotion() ?? false;
      const surface = card?.surface;
      const start = card?.from;
    
      // Stable, so a re-render while it glides doesn't detach and re-attach it (which would stop the glide)
      const attach = useCallback(
        (node: HTMLDivElement | null) => {
          if (typeof ref === "function") ref(node);
          else if (ref) ref.current = node;
          if (!surface || !start || !node) return;
          surface.current = node;
          // Read, not taken: React attaches a ref twice in development (Strict Mode), and taking it on the
          // first attach left the real one with nothing to glide from. Each opening sets it afresh.
          const from = start.current;
          let stop: (() => void) | undefined;
          let observer: MutationObserver | undefined;
          if (from && from.width > 0 && !reduce) {
            // Radix places the card just after it mounts; until then its wrapper is parked off-screen. That
            // change lands before the browser paints, so the glide starts on the first frame shown.
            const wrapper = node.parentElement;
            const placed = () => !wrapper?.style.transform.includes("-200%");
            if (placed()) stop = glideFrom(node, from);
            else if (wrapper) {
              observer = new MutationObserver(() => {
                if (!placed()) return;
                observer?.disconnect();
                stop = glideFrom(node, from);
              });
              observer.observe(wrapper, { attributes: true, attributeFilter: ["style"] });
            }
          }
          return () => {
            observer?.disconnect();
            stop?.();
            if (surface.current === node) surface.current = null;
          };
        },
        [ref, surface, start, reduce],
      );
    
      return (
        <HoverCardPrimitive.Portal>
          <HoverCardPrimitive.Content
            ref={attach}
            data-slot="hover-card-content"
            data-following={card?.following ? "" : undefined}
            align={align}
            sideOffset={sideOffset}
            className={cn(
              "z-(--z-popover) w-64 rounded-lg border border-border bg-popover p-4 text-popover-foreground shadow-md outline-hidden",
              "origin-(--radix-hover-card-content-transform-origin) data-[state=closed]:animate-popover-out",
              // A card that takes another's place glides from it instead
              !card?.following && "data-[state=open]:animate-popover-in",
              // Handed over to the next card: gone at once, while Radix waits out the closing keyframes
              card?.hidden && "invisible",
              className,
            )}
            {...props}
          >
            {/* One element round the content, so it can come in through the blur while the card glides */}
            <div data-slot="hover-card-body">{children}</div>
          </HoverCardPrimitive.Content>
        </HoverCardPrimitive.Portal>
      );
    }
    
    export { HoverCard, HoverCardContent, HoverCardTrigger };
  5. 5

    Update the import paths to match your project.

API reference

HoverCard

The root. Also accepts every Radix Hover Card Root prop.

PropTypeDefaultDescription
open?boolean–Whether it's open, when you control it.
defaultOpen?booleanfalseWhether it starts open.
onOpenChange?(open: boolean) => void–Called when it opens or closes.
openDelay?number300Milliseconds the pointer rests on the link, or focus stays on it, before the card opens.
closeDelay?number300Milliseconds before it closes once the pointer leaves, so it can move onto the card.
skipDelayDuration?number300Milliseconds after a card closes during which the next one, anywhere on the page, opens at once without the delay, gliding over from the last one instead of opening from its link.

HoverCardTrigger

The link the card previews; it renders an <a>, so give it an href. It has to make sense on its own, because screen readers don't get the card. Use asChild to make your own link the trigger.

PropTypeDefaultDescription
href?string–Where the link goes.
asChild?booleanfalseRender the single child (for example a Next.js Link) as the trigger.

HoverCardContent

The card. It opens out of the link through a soft blur and stays open while the pointer is on it. Links inside it are skipped by Tab. Also accepts every Radix Hover Card Content prop.

PropTypeDefaultDescription
side?"top" | "right" | "bottom" | "left""bottom"The side of the link it opens on. It flips when there's no room.
align?"start" | "center" | "end""center"How it lines up with the link along that side.
sideOffset?number4Gap between the link and the card, in px.

Related components

  • Popover

    An accessible React popover for Next.js and Tailwind CSS: forms, settings and other rich content anchored to a button, built on Radix Popover.

  • Tooltip

    An accessible React tooltip for Next.js and Tailwind CSS: a short hint on hover and keyboard focus, fast across a toolbar, built on Radix Tooltip.

  • Alert Dialog

    An accessible React alert dialog for Next.js that asks people to confirm destructive actions, with focus starting on Cancel, built on Radix Alert Dialog.