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

Sheet

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

An accessible React sheet for Next.js: a side panel that slides in from any edge of the screen, for forms, filters and navigation, built on Radix Dialog.

  • Top
  • Right
  • Bottom
  • Left
import { Button } from "@/components/ui/button"
import {
  Sheet,
  SheetContent,
  SheetDescription,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet"

export function Filters() {
  return (
    <Sheet>
      <SheetTrigger asChild>
        <Button variant="outline">Filters</Button>
      </SheetTrigger>
      <SheetContent side="right">
        <SheetHeader>
          <SheetTitle>Filters</SheetTitle>
          <SheetDescription>Narrow the results.</SheetDescription>
        </SheetHeader>
      </SheetContent>
    </Sheet>
  )
}
sides.tsx
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
  Sheet,
  SheetClose,
  SheetContent,
  SheetDescription,
  SheetFooter,
  SheetHeader,
  SheetTitle,
  SheetTrigger,
} from "@/components/ui/sheet";
import { PanelBottomIcon, PanelLeftIcon, PanelRightIcon, PanelTopIcon } from "lucide-react";
import { useId } from "react";

const sides = [
  { side: "top", label: "Top", Icon: PanelTopIcon },
  { side: "right", label: "Right", Icon: PanelRightIcon },
  { side: "bottom", label: "Bottom", Icon: PanelBottomIcon },
  { side: "left", label: "Left", Icon: PanelLeftIcon },
] as const;

/** The same form in a sheet from each edge, each button named underneath */
export default function SheetSides() {
  const id = useId();
  return (
    <ul className="flex flex-wrap items-end justify-center gap-x-8 gap-y-6">
      {sides.map(({ side, label, Icon }) => (
        <li key={side} className="flex flex-col items-center gap-2">
          <Sheet>
            <SheetTrigger asChild>
              <Button variant="outline" size="icon" aria-label={`Open a sheet from the ${side}`}>
                <Icon />
              </Button>
            </SheetTrigger>
            <SheetContent side={side}>
              <SheetHeader>
                <SheetTitle>Edit profile</SheetTitle>
                <SheetDescription>Change how others see you. Save when you're done.</SheetDescription>
              </SheetHeader>
              {/* Capped, so the fields stay a comfortable width in a sheet across the top or bottom */}
              <div className="grid gap-4 sm:max-w-sm">
                <div className="grid gap-2">
                  <label htmlFor={`${id}-${side}-name`} className="text-sm font-medium">
                    Name
                  </label>
                  <Input id={`${id}-${side}-name`} defaultValue="Maya Chen" autoComplete="name" />
                </div>
                <div className="grid gap-2">
                  <label htmlFor={`${id}-${side}-username`} className="text-sm font-medium">
                    Username
                  </label>
                  <Input id={`${id}-${side}-username`} defaultValue="maya" autoComplete="username" />
                </div>
              </div>
              <SheetFooter>
                <SheetClose asChild>
                  <Button>Save changes</Button>
                </SheetClose>
              </SheetFooter>
            </SheetContent>
          </Sheet>
          <span aria-hidden className="text-xs text-muted-foreground">
            {label}
          </span>
        </li>
      ))}
    </ul>
  );
}

Installation

  1. 1

    Install the dependencies.

    Package manager
    $ pnpm add class-variance-authority lucide-react 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

    Add Button first.

  5. 5

    Copy the source into your project.

    components/ui/sheet.tsx
    "use client";
    
    import { Button } from "@/components/ui/button";
    import { duration, ease, spring } from "@/lib/motion";
    import { cn } from "@/lib/utils";
    import { cva } from "class-variance-authority";
    import { XIcon } from "lucide-react";
    import { AnimatePresence, motion, useReducedMotion } from "motion/react";
    import { Dialog as SheetPrimitive } from "radix-ui";
    import { type ComponentProps, createContext, useCallback, useContext, useState } from "react";
    
    /*
     * Sheet: a panel that slides in from an edge of the screen, for forms, filters, navigation or details beside
     * the page. Built on Radix Dialog, so it's a modal: focus moves in and stays there, Escape and the backdrop
     * close it, and focus goes back to the trigger.
     * - The root keeps the open state, controlled (`open`) or not (`defaultOpen`), so the panel can slide out:
     *   the portal stays mounted (forceMount) inside AnimatePresence until the exit ends.
     * - Motion (.agents/rules/motion.md): a drawer slides. The panel comes in from its edge on spring.snappy and
     *   leaves on a timed duration.base + ease.in. No blur: it's a surface moving, not content appearing. The
     *   backdrop only fades. With reduced motion, both only fade.
     * - An 8px band in the panel's own colour lies just past the screen edge, so the spring's slight overshoot
     *   never opens a gap between the panel and the edge.
     * - While it leaves, clicks go through to the page, so the page is usable again at once. Focus then goes
     *   back to the trigger, unless the person has already focused something else there.
     */
    
    type Side = "top" | "right" | "bottom" | "left";
    
    const SheetContext = createContext<boolean | null>(null);
    
    function useSheetOpen(part: string) {
      const open = useContext(SheetContext);
      if (open === null) throw new Error(`${part} must be used inside Sheet`);
      return open;
    }
    
    /** Leaving is timed rather than a spring, so it's over quickly */
    const leave = { duration: duration.base, ease: ease.in };
    
    /** Where the panel waits off-screen, beyond its edge */
    const offscreen = {
      top: { y: "-100%" },
      right: { x: "100%" },
      bottom: { y: "100%" },
      left: { x: "-100%" },
    } as const satisfies Record<Side, object>;
    
    const sheetVariants = cva(
      [
        "fixed z-(--z-modal) flex flex-col gap-4 overflow-y-auto bg-background p-6 text-foreground shadow-lg outline-hidden",
        // Leaving, it no longer catches clicks
        "data-[state=closed]:pointer-events-none",
      ],
      {
        variants: {
          // Each side sits 8px past its edge with an 8px border in the panel's colour there (the overshoot band),
          // and a hairline on the side facing the page
          side: {
            top: "inset-x-0 -top-2 max-h-full border-t-8 border-b border-t-background border-b-border",
            right:
              "inset-y-0 -right-2 h-full w-3/4 border-r-8 border-l border-r-background border-l-border sm:max-w-sm",
            bottom: "inset-x-0 -bottom-2 max-h-full border-t border-b-8 border-t-border border-b-background",
            left: "inset-y-0 -left-2 h-full w-3/4 border-r border-l-8 border-r-border border-l-background sm:max-w-sm",
          },
        },
        defaultVariants: { side: "right" },
      },
    );
    
    function Sheet({ open, defaultOpen, onOpenChange, ...props }: ComponentProps<typeof SheetPrimitive.Root>) {
      const [own, setOwn] = useState(defaultOpen ?? false);
      const current = open ?? own;
      const setOpen = useCallback(
        (next: boolean) => {
          setOwn(next);
          onOpenChange?.(next);
        },
        [onOpenChange],
      );
      return (
        <SheetContext.Provider value={current}>
          <SheetPrimitive.Root data-slot="sheet" open={current} onOpenChange={setOpen} {...props} />
        </SheetContext.Provider>
      );
    }
    
    function SheetTrigger(props: ComponentProps<typeof SheetPrimitive.Trigger>) {
      return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
    }
    
    function SheetClose(props: ComponentProps<typeof SheetPrimitive.Close>) {
      return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
    }
    
    /** Stays mounted while the sheet is open and while its exit plays */
    function SheetPortal({
      children,
      ...props
    }: Omit<ComponentProps<typeof SheetPrimitive.Portal>, "forceMount">) {
      const open = useSheetOpen("SheetPortal");
      return (
        // initial={false}: a sheet that starts open is simply there, with no entrance
        <AnimatePresence initial={false}>
          {open ? (
            <SheetPrimitive.Portal data-slot="sheet-portal" {...props} forceMount>
              {children}
            </SheetPrimitive.Portal>
          ) : null}
        </AnimatePresence>
      );
    }
    
    function SheetOverlay({ className, style, ...props }: ComponentProps<typeof SheetPrimitive.Overlay>) {
      const open = useSheetOpen("SheetOverlay");
      return (
        <SheetPrimitive.Overlay
          data-slot="sheet-overlay"
          className={cn("fixed inset-0 z-(--z-overlay) bg-overlay", className)}
          // Radix keeps the backdrop clickable; while it fades out, clicks go through to the page
          style={{ pointerEvents: open ? "auto" : "none", ...style }}
          {...props}
          asChild
          forceMount
        >
          {/* The backdrop only fades: a full-screen blur would cost too much (ADR 0011) */}
          <motion.div
            initial={{ opacity: 0 }}
            animate={{ opacity: 1, transition: { duration: duration.base, ease: ease.out } }}
            exit={{ opacity: 0, transition: leave }}
          />
        </SheetPrimitive.Overlay>
      );
    }
    
    export type SheetContentProps = ComponentProps<typeof SheetPrimitive.Content> & {
      /** The edge it slides in from */
      side?: Side;
      /** Show a close button in the top corner */
      showCloseButton?: boolean;
    };
    
    function SheetContent({
      className,
      children,
      side = "right",
      showCloseButton = true,
      onCloseAutoFocus,
      ...props
    }: SheetContentProps) {
      const reduce = useReducedMotion() ?? false;
      const hidden = reduce ? { opacity: 0 } : offscreen[side];
      return (
        <SheetPortal>
          <SheetOverlay />
          <SheetPrimitive.Content
            data-slot="sheet-content"
            data-side={side}
            className={cn(
              sheetVariants({ side }),
              // The header stops short of the close button, so a long title never runs under it
              showCloseButton && "**:data-[slot=sheet-header]:pe-8",
              className,
            )}
            onCloseAutoFocus={(event) => {
              onCloseAutoFocus?.(event);
              // Once the panel is gone, focus goes back to the trigger, unless the person has already focused
              // something on the page (clicks reach it while the panel leaves)
              if (document.activeElement !== document.body) event.preventDefault();
            }}
            {...props}
            asChild
            forceMount
          >
            <motion.div
              initial={hidden}
              animate={
                reduce
                  ? { opacity: 1, transition: { duration: duration.base, ease: ease.out } }
                  : { x: 0, y: 0, transition: spring.snappy }
              }
              exit={{ ...hidden, transition: leave }}
            >
              {children}
              {showCloseButton ? (
                <SheetPrimitive.Close asChild>
                  {/* Centred on the title's line, its icon lined up with the panel's padding */}
                  <Button
                    data-slot="sheet-close"
                    variant="ghost"
                    size="icon-sm"
                    aria-label="Close"
                    className="absolute end-4 top-5 text-muted-foreground hover:text-foreground"
                  >
                    <XIcon />
                  </Button>
                </SheetPrimitive.Close>
              ) : null}
            </motion.div>
          </SheetPrimitive.Content>
        </SheetPortal>
      );
    }
    
    function SheetHeader({ className, ...props }: ComponentProps<"div">) {
      return <div data-slot="sheet-header" className={cn("grid gap-1.5", className)} {...props} />;
    }
    
    /** Actions at the panel's foot: stacked on phones with the main one on top, in a row at the end from sm */
    function SheetFooter({ className, ...props }: ComponentProps<"div">) {
      return (
        <div
          data-slot="sheet-footer"
          className={cn("mt-auto flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
          {...props}
        />
      );
    }
    
    function SheetTitle({ className, ...props }: ComponentProps<typeof SheetPrimitive.Title>) {
      return (
        <SheetPrimitive.Title
          data-slot="sheet-title"
          className={cn("text-base font-semibold", className)}
          {...props}
        />
      );
    }
    
    function SheetDescription({ className, ...props }: ComponentProps<typeof SheetPrimitive.Description>) {
      return (
        <SheetPrimitive.Description
          data-slot="sheet-description"
          className={cn("text-sm text-muted-foreground", className)}
          {...props}
        />
      );
    }
    
    export {
      Sheet,
      SheetClose,
      SheetContent,
      SheetDescription,
      SheetFooter,
      SheetHeader,
      SheetTitle,
      SheetTrigger,
      sheetVariants,
    };
  6. 6

    Update the import paths to match your project.

API reference

Sheet

The root. It keeps the open state, so the panel can slide out before it unmounts.

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: the trigger, a close button, Escape or the backdrop.
modal?booleantrueModal blocks the page behind, hides it from screen readers and keeps focus inside. Set false to let people use the page while it's open (there's no backdrop then).

SheetTrigger

The button that opens it. Focus comes back here when it closes.

PropTypeDefaultDescription
asChild?booleanfalseUse your own element, such as a Button, as the trigger.

SheetContent

The panel, with its backdrop, in a portal. It slides in from its edge on a spring and leaves quickly; with reduced motion it only fades. Long content scrolls inside it. Give it a SheetTitle, which names it. Also accepts every Radix Dialog Content prop.

PropTypeDefaultDescription
side?"top" | "right" | "bottom" | "left""right"The edge it slides in from. Right and left panels are three quarters of the screen wide, up to 24rem; top and bottom ones are as tall as their content.
showCloseButton?booleantrueShows a close button in the top corner, named Close.
onOpenAutoFocus?(event: Event) => void–Runs as focus moves in (to the first focusable element). Call event.preventDefault() to focus something else yourself.
onCloseAutoFocus?(event: Event) => void–Runs as focus goes back to the trigger. Call event.preventDefault() to send it elsewhere.
onEscapeKeyDown?(event: KeyboardEvent) => void–Runs when Escape is pressed. Call event.preventDefault() to keep it open.

SheetHeader

Holds the title and description. It leaves room for the close button.

PropTypeDefaultDescription

SheetFooter

Holds the actions at the panel's foot: stacked on phones with the last one on top, in a row at the end from the sm breakpoint.

PropTypeDefaultDescription

SheetTitle

The heading, and the sheet's accessible name.

PropTypeDefaultDescription

SheetDescription

A line under the title, read out after the name.

PropTypeDefaultDescription

SheetClose

Closes the sheet. Use asChild to make your own Button close it.

PropTypeDefaultDescription
asChild?booleanfalseUse your own element as the close button.

Related components

  • Dialog

    An accessible React dialog for Next.js and Tailwind CSS: a modal window that grows out of its trigger, keeps focus inside and returns it, built on Radix Dialog.

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

  • Right-click here
    Context Menu

    An accessible React context menu for Next.js: a right-click menu with a gliding highlight, shortcuts, checkbox and radio items and sub-menus, built on Radix.