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. Popover

Popover

pnpm dlx shadcn@latest add …/r/popover.json

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

import { Button } from "@/components/ui/button"
import {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
  PopoverTrigger,
} from "@/components/ui/popover"

export function Dimensions() {
  return (
    <Popover>
      <PopoverTrigger asChild>
        <Button variant="outline">Dimensions</Button>
      </PopoverTrigger>
      <PopoverContent>
        <PopoverHeader>
          <PopoverTitle>Dimensions</PopoverTitle>
          <PopoverDescription>Set the size of the layer.</PopoverDescription>
        </PopoverHeader>
      </PopoverContent>
    </Popover>
  )
}
uses.tsx
"use client";

import { Button } from "@/components/ui/button";
import { CopyButton } from "@/components/ui/copy-button";
import { Input } from "@/components/ui/input";
import {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
  PopoverTrigger,
} from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { useId, useState } from "react";

const size = [
  { key: "width", label: "Width", value: "100%" },
  { key: "height", label: "Height", value: "25px" },
];

const limits = [
  { key: "max-width", label: "Max. width", value: "300px" },
  { key: "max-height", label: "Max. height", value: "none" },
];

const link = "https://ui.fewtone.com/components/popover";

function Field({ id, label, value }: { id: string; label: string; value: string }) {
  return (
    <div className="grid grid-cols-3 items-center gap-4">
      <label htmlFor={id} className="text-sm">
        {label}
      </label>
      <Input id={id} defaultValue={value} size="sm" className="col-span-2" />
    </div>
  );
}

/** Turning on Limit size adds two fields, and the popover glides to its new height */
function Dimensions() {
  const id = useId();
  const [limit, setLimit] = useState(false);

  return (
    <Popover>
      <PopoverTrigger asChild>
        <Button variant="outline">Dimensions</Button>
      </PopoverTrigger>
      <PopoverContent className="grid w-80 gap-4">
        <PopoverHeader>
          <PopoverTitle>Dimensions</PopoverTitle>
          <PopoverDescription>Set the size of the layer.</PopoverDescription>
        </PopoverHeader>
        <div className="grid gap-2">
          {size.map((field) => (
            <Field key={field.key} id={`${id}-${field.key}`} label={field.label} value={field.value} />
          ))}
          <div className="grid h-(--size-control-sm) grid-cols-3 items-center gap-4">
            <label htmlFor={`${id}-limit`} className="text-sm">
              Limit size
            </label>
            <Switch id={`${id}-limit`} checked={limit} onCheckedChange={setLimit} />
          </div>
          {limit ? (
            // The new fields come in through the blur while the popover glides open around them
            <div className="grid animate-popover-in gap-2">
              {limits.map((field) => (
                <Field key={field.key} id={`${id}-${field.key}`} label={field.label} value={field.value} />
              ))}
            </div>
          ) : null}
        </div>
      </PopoverContent>
    </Popover>
  );
}

function Share() {
  const id = useId();

  return (
    <Popover>
      <PopoverTrigger asChild>
        <Button variant="outline">Share</Button>
      </PopoverTrigger>
      <PopoverContent className="grid w-80 gap-4">
        <PopoverHeader>
          <PopoverTitle>Share this page</PopoverTitle>
          <PopoverDescription>Anyone with the link can view it.</PopoverDescription>
        </PopoverHeader>
        <div className="flex gap-2">
          <label htmlFor={id} className="sr-only">
            Link
          </label>
          <Input id={id} value={link} readOnly size="sm" />
          <CopyButton value={link} variant="outline" size="icon-sm" aria-label="Copy link" />
        </div>
      </PopoverContent>
    </Popover>
  );
}

export default function PopoverUses() {
  return (
    <div className="flex flex-wrap items-center justify-center gap-3">
      <Dimensions />
      <Share />
    </div>
  );
}

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/popover.tsx
    "use client";
    
    import { duration, ease } from "@/lib/motion";
    import { cn } from "@/lib/utils";
    import { type AnimationPlaybackControls, animate, useReducedMotion } from "motion/react";
    import { Popover as PopoverPrimitive } from "radix-ui";
    import { type ComponentProps, useLayoutEffect, useRef } from "react";
    
    /*
     * Popover, built on Radix Popover: rich content anchored to a trigger, in a non-modal dialog. Focus moves
     * into it on open and back to the trigger on close; Escape and a click outside close it.
     * - The surface is Select's list: a bordered popover-coloured card with a soft shadow. It opens out of its
     *   trigger and closes back into it through the blur (the popover-in/out keyframes, scaling from Radix's
     *   transform origin), so it always grows from the side it's attached to.
     * - When what's inside changes size (more fields, a message, the next step), the popover glides to its new
     *   height instead of jumping: the height pattern in motion.md, an eased tween, clipped only while it
     *   moves. Instant with reduced motion; nothing moves on first paint.
     * - PopoverTitle names the dialog and PopoverDescription describes it (Radix links them).
     */
    
    function Popover(props: ComponentProps<typeof PopoverPrimitive.Root>) {
      return <PopoverPrimitive.Root data-slot="popover" {...props} />;
    }
    
    function PopoverTrigger(props: ComponentProps<typeof PopoverPrimitive.Trigger>) {
      return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
    }
    
    /** Positions the popover against another element than its trigger, for example a whole field */
    function PopoverAnchor(props: ComponentProps<typeof PopoverPrimitive.Anchor>) {
      return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
    }
    
    function PopoverClose(props: ComponentProps<typeof PopoverPrimitive.Close>) {
      return <PopoverPrimitive.Close data-slot="popover-close" {...props} />;
    }
    
    /**
     * Glides the element to its new height whenever its content changes size. Only height moves, on the
     * height pattern's tween (a spring's overshoot would bounce); the content is clipped only while it moves.
     */
    function useGlidingHeight(ref: { current: HTMLElement | null }) {
      const reduce = useReducedMotion() ?? false;
    
      useLayoutEffect(() => {
        const node = ref.current;
        if (!node || reduce) return;
        // Layout height: the opening scale is a transform, so it doesn't count
        let height = node.offsetHeight;
        let glide: AnimationPlaybackControls | undefined;
        const release = () => {
          node.style.height = "";
          node.style.overflow = "";
        };
        const observer = new ResizeObserver(() => {
          // While it glides the height is ours; a change underneath shows up once it lets go, and glides again
          if (glide) return;
          const next = node.offsetHeight;
          if (next === height) return;
          const from = height;
          height = next;
          // ResizeObserver runs after layout and before paint, so the new size is never shown before the glide
          node.style.height = `${from}px`;
          node.style.overflow = "hidden";
          glide = animate(
            node,
            { height: [from, next] },
            {
              duration: duration.slow,
              ease: ease.out,
              onComplete: () => {
                glide = undefined;
                release();
              },
            },
          );
        });
        observer.observe(node);
        return () => {
          observer.disconnect();
          glide?.stop();
          release();
        };
      }, [ref, reduce]);
    }
    
    /** The content itself. It mounts with each opening, so the height is measured fresh every time. */
    function PopoverSurface({
      className,
      align = "center",
      sideOffset = 4,
      ref,
      ...props
    }: ComponentProps<typeof PopoverPrimitive.Content>) {
      const node = useRef<HTMLDivElement>(null);
      useGlidingHeight(node);
    
      return (
        <PopoverPrimitive.Content
          data-slot="popover-content"
          ref={(element) => {
            node.current = element;
            if (typeof ref === "function") ref(element);
            else if (ref) ref.current = element;
          }}
          align={align}
          sideOffset={sideOffset}
          className={cn(
            "z-(--z-popover) w-72 rounded-lg border border-border bg-popover p-4 text-popover-foreground shadow-md outline-hidden",
            "origin-(--radix-popover-content-transform-origin) data-[state=open]:animate-popover-in data-[state=closed]:animate-popover-out",
            className,
          )}
          {...props}
        />
      );
    }
    
    function PopoverContent(props: ComponentProps<typeof PopoverPrimitive.Content>) {
      return (
        <PopoverPrimitive.Portal>
          <PopoverSurface {...props} />
        </PopoverPrimitive.Portal>
      );
    }
    
    function PopoverHeader({ className, ...props }: ComponentProps<"div">) {
      return <div data-slot="popover-header" className={cn("grid gap-1", className)} {...props} />;
    }
    
    /** Names the popover for screen readers */
    function PopoverTitle({ className, ...props }: ComponentProps<typeof PopoverPrimitive.Title>) {
      return (
        <PopoverPrimitive.Title
          data-slot="popover-title"
          className={cn("text-sm font-medium", className)}
          {...props}
        />
      );
    }
    
    function PopoverDescription({ className, ...props }: ComponentProps<typeof PopoverPrimitive.Description>) {
      return (
        <PopoverPrimitive.Description
          data-slot="popover-description"
          className={cn("text-sm text-muted-foreground", className)}
          {...props}
        />
      );
    }
    
    export {
      Popover,
      PopoverAnchor,
      PopoverClose,
      PopoverContent,
      PopoverDescription,
      PopoverHeader,
      PopoverTitle,
      PopoverTrigger,
    };
  5. 5

    Update the import paths to match your project.

Examples

Side and alignment

A popover opens on any side of its trigger and lines up with its start, centre or end. It grows out of the edge it's attached to, and flips when there's no room.

sides.tsx
"use client";

import { Button } from "@/components/ui/button";
import {
  Popover,
  PopoverContent,
  PopoverDescription,
  PopoverHeader,
  PopoverTitle,
  PopoverTrigger,
} from "@/components/ui/popover";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { useId, useState } from "react";

type Align = "start" | "center" | "end";

const sides = [
  { side: "top", label: "Top", opens: "Opens above" },
  { side: "right", label: "Right", opens: "Opens to the right" },
  { side: "bottom", label: "Bottom", opens: "Opens below" },
  { side: "left", label: "Left", opens: "Opens to the left" },
] as const;

const lineUp: Record<Align, string> = {
  start: "lined up with the start of its button",
  center: "centred on its button",
  end: "lined up with the end of its button",
};

/*
 * One popover per side, with the alignment in the top-right corner. Each one opens out of its button's
 * edge. An empty row at the bottom, as tall as the setting's row, keeps the buttons centred.
 */
export default function PopoverSides() {
  const id = useId();
  const [align, setAlign] = useState<Align>("center");

  return (
    <div className="flex w-full flex-col gap-6 self-stretch">
      <div className="-mt-6 -mr-6 flex items-center justify-end gap-2">
        <label htmlFor={`${id}-align`} className="text-sm text-muted-foreground">
          Align
        </label>
        <Select value={align} onValueChange={(value) => setAlign(value as Align)}>
          <SelectTrigger id={`${id}-align`} size="sm" className="w-28">
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            <SelectItem value="start">Start</SelectItem>
            <SelectItem value="center">Center</SelectItem>
            <SelectItem value="end">End</SelectItem>
          </SelectContent>
        </Select>
      </div>

      <div className="flex flex-1 flex-wrap items-center justify-center gap-3">
        {sides.map(({ side, label, opens }) => (
          <Popover key={side}>
            <PopoverTrigger asChild>
              <Button variant="outline">{label}</Button>
            </PopoverTrigger>
            <PopoverContent side={side} align={align} className="w-56">
              <PopoverHeader>
                <PopoverTitle>{label}</PopoverTitle>
                <PopoverDescription>
                  {opens}, {lineUp[align]}.
                </PopoverDescription>
              </PopoverHeader>
            </PopoverContent>
          </Popover>
        ))}
      </div>

      <div aria-hidden className="-mb-6 h-(--size-control-sm)" />
    </div>
  );
}

Anchored to a field

With PopoverAnchor, the filter button opens the popover but it lines up with the whole search field and takes its width. PopoverClose closes it and focus goes back to the button.

anchor.tsx
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import {
  Popover,
  PopoverAnchor,
  PopoverClose,
  PopoverContent,
  PopoverHeader,
  PopoverTitle,
  PopoverTrigger,
} from "@/components/ui/popover";
import { SlidersHorizontalIcon } from "lucide-react";
import { useId } from "react";

const filters = [
  { key: "archived", label: "Include archived files", on: false },
  { key: "mine", label: "Only files I own", on: true },
  { key: "shared", label: "Shared with me", on: true },
];

/** The button opens it, but it lines up with the whole field and takes its width */
export default function PopoverAnchored() {
  const id = useId();

  return (
    <Popover>
      <PopoverAnchor asChild>
        <div className="flex w-full max-w-sm gap-2">
          <Input type="search" aria-label="Search files" placeholder="Search files…" />
          <PopoverTrigger asChild>
            <Button variant="outline" size="icon" aria-label="Filters">
              <SlidersHorizontalIcon aria-hidden />
            </Button>
          </PopoverTrigger>
        </div>
      </PopoverAnchor>
      <PopoverContent align="start" className="grid w-(--radix-popover-trigger-width) gap-4">
        <PopoverHeader>
          <PopoverTitle>Filters</PopoverTitle>
        </PopoverHeader>
        <div className="grid gap-3">
          {filters.map((filter) => (
            <div key={filter.key} className="flex items-center gap-2">
              <Checkbox id={`${id}-${filter.key}`} size="sm" defaultChecked={filter.on} />
              <label htmlFor={`${id}-${filter.key}`} className="text-sm">
                {filter.label}
              </label>
            </div>
          ))}
        </div>
        <div className="flex justify-end">
          <PopoverClose asChild>
            <Button size="sm">Done</Button>
          </PopoverClose>
        </div>
      </PopoverContent>
    </Popover>
  );
}

API reference

Popover

The root. Also accepts every Radix Popover 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.
modal?booleanfalseKeeps focus inside and makes the rest of the page inert while it's open, like a dialog.

PopoverTrigger

The button that opens and closes it. With asChild, your own Button becomes the trigger. Screen readers hear that it opens a dialog, and whether it's open.

PropTypeDefaultDescription
asChild?booleanfalseRender the single child (for example a Button) as the trigger.

PopoverAnchor

Optional. Positions the popover against this element instead of the trigger, for example a whole field with the trigger inside it.

PropTypeDefaultDescription
asChild?booleanfalseRender the single child as the anchor.

PopoverContent

The popover: a non-modal dialog. It opens from its trigger through a soft blur, and when its content changes size it glides to the new height (instant with reduced motion). Focus moves into it on open and back to the trigger on close; Escape and a click outside close it. Also accepts every Radix Popover Content prop.

PropTypeDefaultDescription
side?"top" | "right" | "bottom" | "left""bottom"The side of the trigger it opens on. It flips when there's no room.
align?"start" | "center" | "end""center"How it lines up with the trigger along that side.
sideOffset?number4Gap between the trigger and the popover, in px.
onOpenAutoFocus?(event: Event) => void–Called when focus moves in on open. Call event.preventDefault() to keep it on the trigger.

PopoverHeader

Optional. Stacks the title and description.

PropTypeDefaultDescription

PopoverTitle

Names the popover for screen readers. Give every popover a title, or an aria-label on PopoverContent.

PropTypeDefaultDescription

PopoverDescription

Optional. Read out after the title when the popover opens.

PropTypeDefaultDescription

PopoverClose

A button that closes the popover. Use asChild to make your own Button close it.

PropTypeDefaultDescription
asChild?booleanfalseRender the single child (for example a Button) as the close button.

Related components

  • 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.

  • Reviewed by @maya, @theo and @ines.

    Hover Card

    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.

  • Select

    An accessible React select with a gliding highlight, groups, labels and a scrollable list, built on Radix Select.