MioUI

Sheet

A drawer that slides in from the left, right or bottom on a GENTLE spring, with the same accessibility as Dialog.

Installation

components/ui/sheet.tsx
'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>
PropTypeDefaultNotes
sideleft right bottomrightSlide-in edge
title / descriptionReactNode—Header
footerReactNode—Pinned footer actions
dismissiblebooleantrueWhether it can be closed
classNamestring—Override width / height

On this page