Skip to content
UI Kit
Accent color

Sheet

A modal panel that slides in from any edge, with a fixed header, a scrolling body and a sticky footer.

One of 9 components in Overlays

2 variants

Variants

Filters from the right

The classic for filters, carts and details. The footer stays while the body scrolls.

Left and bottom

Navigation from the left, short notices from the bottom with a grab handle.

Details

What Sheet brings along, read from the registry and the source.

Category
Overlays
Variants
2
Version
1.0.0 · since 1.0.0
File
components/ui/sheet.tsx
Brings along
utilskiticondialog
npm packages
None
Languages
English and German, 1 text of its own
Studio
Adjust in code

Props

The SheetProps interface from components/ui/sheet.tsx, with the comments from the code. Also takes every prop of Omit<SheetPanelProps, 'onClose' | 'titleId' | 'descriptionId' | 'footer'>.

  • triggeroptional
    React.ReactElement
  • openoptional
    boolean
  • defaultOpenoptional
    boolean
  • onOpenChangeoptional
    (open: boolean) => void
  • footeroptional
    React.ReactNode | ((close: () => void) => React.ReactNode)

Built-in texts

Sheet writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 1 texts
closeEnglish CloseGerman Schließen

Accessibility

Read from the source of the component.

  • Built on the native dialog element. The browser keeps focus inside and closes it with Escape.
  • Sets aria-describedby, aria-label and aria-labelledby, so screen readers announce purpose and state.
  • Needs no client state. The server writes it in full, so it reads without JavaScript too.

Motion

  • With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.
  • No animations, it stands still from the start.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 109 lines
components/ui/sheet.tsx
'use client';

import { useId } from 'react';
import { cn, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { DialogMotionStyle, scrim, useDialogState, useModalMotion, withTrigger } from '@/components/ui/dialog';

const TEXTS = { en: { close: 'Close' }, de: { close: 'Schließen' } };

export type SheetSide = 'left' | 'right' | 'top' | 'bottom';

/* Where the sheet rests. It slides in and out from its edge (see useModalMotion), so no translate here. */
const PLACE: Record<SheetSide, string> = {
  right: 'ms-auto me-0 h-dvh max-h-dvh w-[min(26rem,calc(100vw-2.5rem))]',
  left: 'ms-0 me-auto h-dvh max-h-dvh w-[min(22rem,calc(100vw-2.5rem))]',
  top: 'mt-0 mb-auto w-full max-w-none',
  bottom: 'mb-0 mt-auto max-h-[88dvh] w-full max-w-none',
};

const SURFACE: Record<SheetSide, string> = {
  right: 'border-s',
  left: 'border-e',
  top: 'border-b rounded-b-2xl',
  bottom: 'border-t rounded-t-2xl',
};

export interface SheetPanelProps {
  side?: SheetSide;
  title: React.ReactNode;
  description?: React.ReactNode;
  footer?: React.ReactNode;
  onClose?: () => void;
  titleId?: string;
  descriptionId?: string;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
  children?: React.ReactNode;
}

/** The surface of a sheet: header with close, a scrolling body and a sticky footer. */
export function SheetPanel({ side = 'right', title, description, footer, onClose, titleId, descriptionId, labels, className, children }: SheetPanelProps) {
  const t = useTexts(TEXTS, labels);
  const vertical = side === 'top' || side === 'bottom';
  return (
    <div className={cn('flex size-full max-h-[inherit] flex-col border-border bg-popover text-popover-foreground shadow-[var(--shadow-lg)]', SURFACE[side], className)}>
      {side === 'bottom' && <span aria-hidden className="mx-auto mt-2.5 h-1 w-10 shrink-0 rounded-full bg-border-strong" />}
      <div className={cn('kitfx-dialog-part flex shrink-0 items-start gap-4 px-6 pb-4', side === 'bottom' ? 'pt-4' : 'pt-6', vertical && 'mx-auto w-full max-w-[44rem]')}>
        <div className="min-w-0 flex-1">
          <h2 id={titleId} className="text-[17px] font-semibold leading-snug tracking-[-0.015em] text-foreground">
            {title}
          </h2>
          {description && (
            <p id={descriptionId} className="mt-1 text-pretty text-sm leading-relaxed text-muted-foreground">
              {description}
            </p>
          )}
        </div>
        <button type="button" onClick={onClose} aria-label={t.close} className="group/close -me-2 -mt-1 grid size-9 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground">
          <Icon name="x" size={17} className="transition-transform duration-300 ease-[var(--ease-kit)] group-hover/close:rotate-90 motion-reduce:transition-none" />
        </button>
      </div>
      {children && <div className={cn('kitfx-dialog-part kit-scrollbar min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 pb-6', vertical && 'mx-auto w-full max-w-[44rem]')}>{children}</div>}
      {footer && (
        <div className="kitfx-dialog-part shrink-0 border-t border-border bg-surface px-6 py-4">
          <div className={cn('flex flex-col-reverse gap-2.5 sm:flex-row sm:justify-end [&>*]:max-sm:w-full', vertical && 'mx-auto max-w-[44rem]')}>{footer}</div>
        </div>
      )}
    </div>
  );
}

export interface SheetProps extends Omit<SheetPanelProps, 'onClose' | 'titleId' | 'descriptionId' | 'footer'> {
  trigger?: React.ReactElement;
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  footer?: React.ReactNode | ((close: () => void) => React.ReactNode);
}

/**
 * A panel that slides in from an edge: filters, carts, menus, details. Modal, on the native <dialog>.
 * It slides back out before it closes, its parts arrive one after another.
 *   <Sheet side="right" trigger={<Button>Filters</Button>} title="Filters">…</Sheet>
 */
export function Sheet({ trigger, open: openProp, defaultOpen, onOpenChange, side = 'right', footer, children, ...panel }: SheetProps) {
  const [open, setOpen] = useDialogState(openProp, defaultOpen, onOpenChange);
  const [modal, present] = useModalMotion(open, setOpen, { motion: side });
  const id = useId();
  const close = () => setOpen(false);
  return (
    <>
      {trigger && withTrigger(trigger, open, setOpen, id)}
      <DialogMotionStyle />
      <dialog
        {...modal}
        id={id}
        aria-labelledby={`${id}-t`}
        aria-describedby={panel.description ? `${id}-d` : undefined}
        className={cn('kitfx-dialog overflow-visible bg-transparent p-0 text-left outline-none data-[closing]:pointer-events-none', PLACE[side], scrim)}
      >
        {present && (
          <SheetPanel {...panel} side={side} titleId={`${id}-t`} descriptionId={`${id}-d`} onClose={close} footer={typeof footer === 'function' ? footer(close) : footer}>
            {children}
          </SheetPanel>
        )}
      </dialog>
    </>
  );
}

Frequent questions

How do I install Sheet?

With aiw add @ai-website/sheet. The command puts components/ui/sheet.tsx into your project, the code is yours from then on. It brings along icon and dialog. It needs no further npm packages.

Which variants does Sheet have?

2 variants, Filters from the right and Left and bottom.

Can I translate the texts of Sheet?

Yes. Sheet carries 1 texts of its own in English and German, such as “Close”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Sheet?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Sheet with aiw_components and install it with aiw_add @ai-website/sheet.

Often searched

Jump to

Copied to the clipboard