Components

Getting started

Components144

More

A panel that slides in from an edge and can be thrown back: drag it toward its edge and release speed decides whether it closes.

Used forDetail panelsMobile menus and filtersSettings drawersCarts

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/sheet.json

Templates

Need the whole page?

43 Next.js landing pages, each designed from scratch. Try any of them live; one is $39, all of them $99, and 5 are free.

All 43 templates

Where it’s used

  • Detail panels

    An order or ticket beside the list it came from.

  • Mobile menus and filters

    A bottom sheet that drags away.

  • Settings drawers

    Preferences without leaving the page.

  • Carts

    A cart that slides in from the right.

What it handles for you

Drag to dismiss needs slop, rubber-banding and velocity projection to feel right; distance alone makes short flicks fail.

Instead of wiring upVaulRadix Dialog as a drawergesture code
  1. Thrown, not just dragged

    projectInertia decides with release velocity, so a short flick closes it.

  2. Rubber band

    Pulling the wrong way resists instead of stopping dead.

  3. Three edges

    right, left and bottom; the bottom one has a grip.

  4. Native dialog

    Focus trap, Escape and an inert page.

Overview

Side panels usually close only with a button, and bottom sheets that drag use a fixed distance that feels wrong on a short flick. Here you grab the header (or the grip) and pull: it follows 1:1, resists the wrong way, and a throw projected from your release speed or a pull past a third closes it; otherwise it springs back.

States

open
Slides in from its edge (460ms) as the page fades back.
drag
Follows the finger 1:1 after 6px of slop; the wrong way resists.
release
Projected past a third: it carries on out (300ms); otherwise it springs back (380ms).

Usage

usage.tsx
import { Sheet } from "@/components/ui/sheet";<Sheet trigger={<button>Filters</button>} side="bottom" title="Filters">  …</Sheet>

Props

PropTypeDescription
side"right" | "left" | "bottom"default "right"The edge it comes from.
open / defaultOpen / onOpenChangeboolean / (open) => voidState.
triggerReactElementA button that opens it.
title / descriptionReactNodeHeader.
children / footerReactNode | ({ close }) => ReactNodeBody and buttons.
widthnumberdefault 400Side sheet width.

Accessibility and motion

  • Built on the native <dialog> (modal.ts, shared with the other modal surface): focus stays inside, the page is inert, Escape works, and it sits in the top layer.
  • Drag writes transforms to the panel directly: no re-render per move.
  • Reduced motion: it fades; drag still works.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs ui-interaction-motion.

Source

components/sheet.tsx
"use client";import * as React from "react";import { MODAL_ROOT, SCRIM, useModal } from "./modal";import { EASE_OUT, INTERACTION_FOCUS, INTERACTION_PRESS, projectInertia, rubberBand, useInteractionReducedMotion } from "./ui-interaction-motion";/* ───────────────────────────────────────────────────────── * SHEET: a panel that slides in from an edge * *   open     the page fades back; the panel slides in from its *            edge (right, left or bottom) and settles *   drag     grab the panel's header (or the bottom sheet's grip) *            and pull it toward its edge: it follows 1:1, resists *            the other way, and a throw (projected from release *            speed) or a pull past a third closes it; otherwise *            it springs back *   close    Escape, the ✕ or the veil; focus returns to what *            opened it * ───────────────────────────────────────────────────────── */export interface SheetProps {  open?: boolean;  defaultOpen?: boolean;  onOpenChange?: (open: boolean) => void;  trigger?: React.ReactElement<React.HTMLAttributes<HTMLElement>>;  side?: "right" | "left" | "bottom";  title: React.ReactNode;  description?: React.ReactNode;  children?: React.ReactNode | ((api: { close: () => void }) => React.ReactNode);  footer?: React.ReactNode | ((api: { close: () => void }) => React.ReactNode);  /** Width of a side sheet in px. */  width?: number;  className?: string;}export function Sheet({  open: controlled,  defaultOpen = false,  onOpenChange,  trigger,  side = "right",  title,  description,  children,  footer,  width = 400,  className = "",}: SheetProps) {  const reduced = useInteractionReducedMotion();  const [own, setOwn] = React.useState(defaultOpen);  const open = controlled ?? own;  const setOpen = (next: boolean) => {    if (controlled === undefined) setOwn(next);    onOpenChange?.(next);  };  const close = () => setOpen(false);  const { dialog, mounted, shown } = useModal(open, close, 320);  const panel = React.useRef<HTMLDivElement>(null);  const drag = React.useRef<{ start: number; last: number; t: number; v: number; d: number; size: number; moved: boolean } | null>(null);  const titleId = React.useId();  const bottom = side === "bottom";  // +1 when moving toward the edge closes it.  const sign = side === "left" ? -1 : 1;  const hidden = bottom ? "translate3d(0,100%,0)" : `translate3d(${sign * 100}%,0,0)`;  const write = (d: number, animate: boolean) => {    const el = panel.current;    if (!el) return;    el.style.transition = animate ? `transform 380ms ${EASE_OUT}` : "none";    el.style.transform = d ? (bottom ? `translate3d(0,${d}px,0)` : `translate3d(${sign * d}px,0,0)`) : "";  };  const onPointerDown = (e: React.PointerEvent) => {    if (e.button !== 0 || (e.target as HTMLElement).closest("button, a, input, textarea, select")) return;    const el = panel.current;    if (!el) return;    const at = bottom ? e.clientY : e.clientX * sign;    drag.current = { start: at, last: at, t: performance.now(), v: 0, d: 0, size: bottom ? el.offsetHeight : el.offsetWidth, moved: false };    (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);  };  const onPointerMove = (e: React.PointerEvent) => {    const g = drag.current;    if (!g) return;    const at = bottom ? e.clientY : e.clientX * sign;    if (!g.moved && Math.abs(at - g.start) < 6) return;    if (!g.moved) {      g.moved = true;      g.start = at; // no jump when the drag takes over from the slop    }    const now = performance.now();    g.v = (at - g.last) / Math.max(1, now - g.t);    g.last = at;    g.t = now;    const raw = at - g.start;    g.d = raw >= 0 ? raw : rubberBand(raw, g.size, 0.3);    write(g.d, false);  };  const onPointerUp = () => {    const g = drag.current;    drag.current = null;    if (!g || !g.moved) return;    // Closing leaves the panel where the finger let go; the slide out carries on from there.    if (projectInertia(g.d, g.v, 0.12) > g.size / 3) close();    else write(0, true);  };  const footerNode = typeof footer === "function" ? footer({ close }) : footer;  const grip = { onPointerDown, onPointerMove, onPointerUp, onPointerCancel: onPointerUp };  return (    <>      {trigger &&        React.cloneElement(trigger, {          "aria-haspopup": "dialog",          onClick: (e: React.MouseEvent<HTMLElement>) => {            trigger.props.onClick?.(e);            setOpen(true);          },        })}      {mounted && (        <dialog ref={dialog} aria-labelledby={titleId} className={MODAL_ROOT}>          <div className={SCRIM} onClick={close} style={{ opacity: shown ? 1 : 0, transition: `opacity ${shown ? 300 : 240}ms ${EASE_OUT}` }} />          <div            className={`absolute flex ${bottom ? "inset-x-0 bottom-0 justify-center" : `inset-y-0 ${side === "left" ? "left-0" : "right-0"}`} pointer-events-none p-2`}            style={{ width: bottom ? undefined : `min(${width}px, 100vw)` }}          >            <div              className="pointer-events-auto flex size-full"              style={{                transform: shown || reduced ? "none" : hidden,                opacity: reduced ? (shown ? 1 : 0) : 1,                transition: reduced ? "opacity 160ms ease-out" : `transform ${shown ? 460 : 300}ms ${EASE_OUT}`,              }}            >              <div                ref={panel}                className={`flex w-full flex-col bg-background shadow-[0_0_0_1px_var(--border)] will-change-transform ${bottom ? "max-h-[85dvh] max-w-[640px] rounded-[22px]" : "h-full rounded-[20px]"} ${className}`}              >                {bottom && (                  <div {...grip} className="flex cursor-grab touch-none justify-center pb-1 pt-2.5 active:cursor-grabbing">                    <span className="h-1 w-9 rounded-full bg-border" />                  </div>                )}                <div {...grip} className={`flex touch-none items-start gap-4 px-5 ${bottom ? "pt-2" : "cursor-grab pt-5 active:cursor-grabbing"} pb-3`}>                  <div className="flex min-w-0 flex-1 flex-col gap-1">                    <h2 id={titleId} className="text-[16px] font-semibold tracking-[-0.01em] text-foreground">                      {title}                    </h2>                    {description && <p className="text-[13.5px] leading-relaxed text-muted-foreground">{description}</p>}                  </div>                  <button                    type="button"                    aria-label="Close"                    onClick={close}                    className={`-mr-1.5 flex size-8 shrink-0 items-center justify-center rounded-full text-muted-foreground cursor-pointer hover:bg-accent hover:text-foreground ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`}                  >                    <svg viewBox="0 0 16 16" fill="none" className="size-3.5">                      <path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />                    </svg>                  </button>                </div>                <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 pb-4 text-[13.5px] leading-relaxed text-foreground">                  {typeof children === "function" ? children({ close }) : children}                </div>                {footerNode !== undefined && <div className="flex flex-col-reverse gap-2 border-t border-border px-5 py-4 sm:flex-row sm:justify-end">{footerNode}</div>}              </div>            </div>          </div>        </dialog>      )}    </>  );}export default Sheet;
components/modal.ts
"use client";import * as React from "react";/* ───────────────────────────────────────────────────────── * MODAL: the native <dialog> under Dialog and Sheet * *   open     showModal() (focus trap, inert page, top layer) and *            the next frame turns `shown` on for the entry motion *   close    Escape and the scrim ask to close; the surface plays *            its exit, then dialog.close() and focus goes back to *            whatever opened it *   scroll   wheel and touch scrolling outside a scrollable part *            of the panel are stopped at the dialog, so the page *            behind never moves (the page's own styles are never *            touched: no layout shift, no restyle of <html>) * ───────────────────────────────────────────────────────── */export function useModal(open: boolean, onClose: () => void, exit = 220) {  const dialog = React.useRef<HTMLDialogElement>(null);  const [mounted, setMounted] = React.useState(open);  const [shown, setShown] = React.useState(false);  const opener = React.useRef<HTMLElement | null>(null);  const close = React.useRef(onClose);  React.useEffect(() => {    close.current = onClose;  });  if (open && !mounted) setMounted(true);  React.useEffect(() => {    const el = dialog.current;    if (!el) return;    if (open) {      opener.current = document.activeElement as HTMLElement | null;      if (!el.open) el.showModal();      let inner = 0;      const outer = requestAnimationFrame(() => {        inner = requestAnimationFrame(() => setShown(true));      });      return () => {        cancelAnimationFrame(outer);        cancelAnimationFrame(inner);      };    }    const timer = window.setTimeout(() => {      if (el.open) el.close();      setMounted(false);      setShown(false);      opener.current?.focus?.({ preventScroll: true });    }, exit);    return () => window.clearTimeout(timer);  }, [open, mounted, exit]);  React.useEffect(() => {    const el = dialog.current;    if (!el || !mounted) return;    const onCancel = (e: Event) => {      e.preventDefault();      close.current();    };    // Stop scrolling from leaking to the page unless it happens inside something that scrolls.    const guard = (e: Event) => {      let node = e.target as HTMLElement | null;      while (node && node !== el) {        const style = getComputedStyle(node);        if (/(auto|scroll)/.test(style.overflowY) && node.scrollHeight > node.clientHeight) return;        node = node.parentElement;      }      e.preventDefault();    };    el.addEventListener("cancel", onCancel);    el.addEventListener("wheel", guard, { passive: false });    el.addEventListener("touchmove", guard, { passive: false });    return () => {      el.removeEventListener("cancel", onCancel);      el.removeEventListener("wheel", guard);      el.removeEventListener("touchmove", guard);    };  }, [mounted]);  return { dialog, mounted: mounted || open, shown: open && shown };}/** Class for the full-window <dialog> both surfaces sit in. */export const MODAL_ROOT =  "fixed inset-0 m-0 h-dvh max-h-none w-screen max-w-none overflow-hidden bg-transparent p-0 text-foreground outline-none backdrop:bg-transparent";/** The dim behind the surface: the page fades back under a veil of the background colour, no blur. */export const SCRIM = "absolute inset-0 bg-[color-mix(in_oklab,var(--background)_62%,transparent)]";

Registry item: /r/sheet.json