Sheet
A drawer that slides in from the left, right or bottom on a GENTLE spring, with the same accessibility as Dialog.
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Sheet, type SheetSide } from '@/components/ui/sheet';
import { Field, Input, Label, Textarea } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
export default function SheetDemo() {
const [side, setSide] = useState<SheetSide | null>(null);
const [pub, setPub] = useState(true);
return (
<div className="flex flex-wrap justify-center gap-2">
<Button variant="secondary" onClick={() => setSide('left')}>Left</Button>
<Button variant="secondary" onClick={() => setSide('right')}>Right</Button>
<Button variant="secondary" onClick={() => setSide('bottom')}>Bottom</Button>
<Sheet
open={side !== null}
onOpenChange={(o) => !o && setSide(null)}
side={side ?? 'right'}
title="Edit page"
description="Changes go live as soon as you save."
closeLabel="Close"
footer={
<>
<Button variant="ghost" onClick={() => setSide(null)}>Cancel</Button>
<Button onClick={() => setSide(null)}>Save</Button>
</>
}
>
<div className="flex flex-col gap-4 pt-1">
<Field>
<Label htmlFor="sheet-title">Title</Label>
<Input id="sheet-title" defaultValue="About us" />
</Field>
<Field>
<Label htmlFor="sheet-desc">Summary</Label>
<Textarea id="sheet-desc" defaultValue="A small, warm and restrained team." />
</Field>
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-sm font-medium text-text-primary">Public</div>
<div className="text-[13px] text-text-secondary">Only you can see it when off</div>
</div>
<Switch label="Public" checked={pub} onChange={setPub} />
</div>
</div>
</Sheet>
</div>
);
}
Installation
'use client';
import { AnimatePresence, motion } from 'motion/react';
import { X } from '@phosphor-icons/react';
import { useId, useRef, type HTMLAttributes, type ReactNode, type RefObject } from 'react';
import { twMerge } from 'tailwind-merge';
import { SPRING_GENTLE } from '@/lib/motion';
import { useReducedMotionPref } from '@/lib/use-reduced-motion';
import { Portal, useFocusTrap, useLayer, useScrollLock } from './overlay';
/* ==========================================================================
Sheet / Drawer:从 left / right / bottom 滑入(GENTLE 弹簧),无障碍与 Dialog 一致
========================================================================== */
export type SheetSide = 'left' | 'right' | 'bottom';
export interface SheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
side?: SheetSide;
title?: ReactNode;
description?: ReactNode;
children?: ReactNode;
/** 底部固定操作区 */
footer?: ReactNode;
dismissible?: boolean;
closeOnOverlay?: boolean;
hideClose?: boolean;
closeLabel?: string;
'aria-label'?: string;
initialFocus?: RefObject<HTMLElement | null>;
/** 面板类名(覆盖宽度如 w-[480px],或高度) */
className?: string;
}
const offscreen: Record<SheetSide, { x?: string; y?: string }> = {
left: { x: '-100%' },
right: { x: '100%' },
bottom: { y: '100%' },
};
const placement: Record<SheetSide, string> = {
left: 'inset-y-0 left-0 h-full w-[min(380px,calc(100vw-48px))] rounded-r-2xl',
right: 'inset-y-0 right-0 h-full w-[min(380px,calc(100vw-48px))] rounded-l-2xl',
bottom: 'inset-x-0 bottom-0 max-h-[85vh] w-full rounded-t-2xl',
};
export function Sheet({
open,
onOpenChange,
side = 'right',
title,
description,
children,
footer,
dismissible = true,
closeOnOverlay = true,
hideClose,
closeLabel = 'Close',
'aria-label': ariaLabel,
initialFocus,
className,
}: SheetProps) {
const panel = useRef<HTMLDivElement>(null);
const titleId = useId();
const descId = useId();
const reduce = useReducedMotionPref();
const close = () => dismissible && onOpenChange(false);
const isTop = useLayer(open, close);
useScrollLock(open);
useFocusTrap(panel, open, { initialFocus, isTop });
const hidden = reduce ? { opacity: 0 } : offscreen[side];
const shown = reduce ? { opacity: 1 } : { x: 0, y: 0 };
return (
<Portal>
<AnimatePresence>
{open && (
<div className="fixed inset-0 z-50">
<motion.div
aria-hidden
className="absolute inset-0 bg-black/25 dark:bg-black/50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.2, ease: [0.16, 1, 0.3, 1] }}
onClick={() => closeOnOverlay && close()}
/>
<motion.div
ref={panel}
role="dialog"
aria-modal="true"
aria-labelledby={title ? titleId : undefined}
aria-describedby={description ? descId : undefined}
aria-label={title ? undefined : ariaLabel}
tabIndex={-1}
initial={hidden}
animate={shown}
exit={hidden}
transition={reduce ? { duration: 0 } : SPRING_GENTLE}
className={twMerge(
'absolute flex flex-col bg-surface-2 text-text-primary shadow-popover outline-none dark:bg-popover-surface',
placement[side],
className,
)}
>
{side === 'bottom' && (
<div aria-hidden className="mx-auto mt-2.5 h-1 w-9 shrink-0 rounded-full bg-hairline-strong" />
)}
{(title || description || !hideClose) && (
<div className={twMerge('flex shrink-0 items-start gap-3 px-5', side === 'bottom' ? 'pt-3' : 'pt-5', 'pb-3')}>
<div className="flex min-w-0 flex-1 flex-col gap-1">
{title && (
<h2 id={titleId} className="font-title text-[17px] leading-snug text-text-primary">
{title}
</h2>
)}
{description && (
<div id={descId} className="text-sm leading-relaxed text-text-secondary">
{description}
</div>
)}
</div>
{!hideClose && (
<button
type="button"
aria-label={closeLabel}
disabled={!dismissible}
onClick={close}
data-focus-skip
className="cds-focus -mr-1.5 -mt-0.5 inline-flex size-7 shrink-0 items-center justify-center rounded-lg text-text-muted transition-colors duration-[120ms] ease-cds hover:bg-ghost-hover hover:text-text-primary disabled:opacity-40 [&_svg]:size-4"
>
<X />
</button>
)}
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto px-5 pb-5">{children}</div>
{footer && <SheetFooter>{footer}</SheetFooter>}
</motion.div>
</div>
)}
</AnimatePresence>
</Portal>
);
}
/** Drawer 是 Sheet 的别名(常用于移动端底部抽屉:side="bottom") */
export const Drawer = Sheet;
export type DrawerProps = SheetProps;
export function SheetFooter({ className, ...p }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={twMerge('flex shrink-0 justify-end gap-2 border-t border-hairline px-5 py-3', className)}
{...p}
/>
);
}
Usage
import { Sheet } from "@/components/ui/sheet";
<Sheet open={open} onOpenChange={setOpen} side="right" title="Edit page" footer={<Button>Save</Button>}>
…
</Sheet>| Prop | Type | Default | Notes |
|---|---|---|---|
side | left right bottom | right | Slide-in edge |
title / description | ReactNode | — | Header |
footer | ReactNode | — | Pinned footer actions |
dismissible | boolean | true | Whether it can be closed |
className | string | — | Override width / height |