Agents (llms.txt)
Octocat

Drawer

A slide-in panel that overlays the main content.

import { Button } from '@/components/button';
import { Drawer } from '@/components/drawer';

const DrawerPreview = () => {
  return (
    <Drawer>
      <Drawer.Trigger asChild>
        <Button>Open Drawer</Button>
      </Drawer.Trigger>
      <Drawer.Content>
        <Drawer.Header>
          <Drawer.Title>Drawer Title</Drawer.Title>
        </Drawer.Header>
        <Drawer.Description>Drawer content goes here.</Drawer.Description>
        <Drawer.Actions className="flex gap-2">
          <Drawer.Close asChild>
            <Button variant="primary" className="grow">
              Submit
            </Button>
          </Drawer.Close>
          <Drawer.Close asChild>
            <Button className="grow" variant="outline">
              Close
            </Button>
          </Drawer.Close>
        </Drawer.Actions>
      </Drawer.Content>
    </Drawer>
  );
};

export default DrawerPreview;

Dependencies

Source Code

import { Modal } from '@/components/modal';
import { cn, cva } from '@/lib/utils/classnames';

type DrawerProps = React.ComponentProps<typeof Modal>;

const Drawer = (props: DrawerProps) => {
  return <Modal {...props} />;
};

type DrawerSide = 'left' | 'right' | 'bottom' | 'left-bottom' | 'right-bottom';

const drawerContentStyle = cva({
  base: [
    '[--drawer-detach:calc(var(--radius)*3*var(--radius-bump))] [--drawer-p:--spacing(4)] [--drawer-stack:--spacing(4)]',
    'overflow-x-hidden! flex w-full max-w-screen flex-col overflow-y-auto border border-border bg-background p-(--drawer-p) *:shrink-0',
    'has-[[data-modal-focus-catcher]:first-child+[data-drawer-header],[data-drawer-header]:first-child]:pt-0 has-[[data-drawer-actions]:last-child]:pb-0',
    'rounded-2xl',
    'has-data-[status=open]:opacity-80 [&>:not(dialog)]:transition-opacity has-data-[status=open]:[&>:not(dialog)]:opacity-0',
    'backdrop:bg-black/20 in-data-[status=open]:backdrop:opacity-0 not-data-[status=open]:backdrop:opacity-0 backdrop:backdrop-blur-sm',
    'transition-[translate,margin,opacity] ease-emphasized-decelerate not-data-[status=open]:ease-emphasized-accelerate',
    'backdrop:transition-opacity backdrop:ease-in-out',
    'motion-reduce:transition-none motion-reduce:backdrop:transition-none',
    'duration-400 not-data-[status=open]:duration-250 not-data-[status=open]:backdrop:delay-150 not-data-[status=open]:backdrop:duration-200',
  ],
  variants: {
    side: {
      right:
        'my-(--drawer-detach) mr-(--drawer-detach) ml-auto h-[calc(100dvh-2*var(--drawer-detach))] max-h-none max-w-lg not-data-[status=open]:translate-x-full has-data-[status=open]:mr-[calc(var(--drawer-detach)+var(--drawer-stack))]',
      left: 'my-(--drawer-detach) mr-auto ml-(--drawer-detach) h-[calc(100dvh-2*var(--drawer-detach))] max-h-none max-w-lg not-data-[status=open]:-translate-x-full has-data-[status=open]:ml-[calc(var(--drawer-detach)+var(--drawer-stack))]',
      bottom:
        'mx-(--drawer-detach) mt-auto mb-(--drawer-detach) max-h-[calc(100svh-(--spacing(16)))] min-h-[50svh] w-[calc(100%-2*var(--drawer-detach))] not-data-[status=open]:translate-y-full has-data-[status=open]:mb-[calc(var(--drawer-detach)+var(--drawer-stack))]',
      'right-bottom': [
        'md:my-(--drawer-detach) md:mr-(--drawer-detach) md:ml-auto md:h-[calc(100dvh-2*var(--drawer-detach))] md:max-h-none md:max-w-lg md:not-data-[status=open]:translate-x-full md:has-data-[status=open]:mr-[calc(var(--drawer-detach)+var(--drawer-stack))]',
        'max-md:mx-(--drawer-detach) max-md:mt-auto max-md:mb-(--drawer-detach) max-md:max-h-[calc(100svh-(--spacing(16)))] max-md:min-h-[50svh] max-md:w-[calc(100%-2*var(--drawer-detach))] max-md:not-data-[status=open]:translate-y-full max-md:has-data-[status=open]:mb-[calc(var(--drawer-detach)+var(--drawer-stack))]',
      ],
      'left-bottom': [
        'md:my-(--drawer-detach) md:mr-auto md:ml-(--drawer-detach) md:h-[calc(100dvh-2*var(--drawer-detach))] md:max-h-none md:max-w-lg md:not-data-[status=open]:-translate-x-full md:has-data-[status=open]:ml-[calc(var(--drawer-detach)+var(--drawer-stack))]',
        'max-md:mx-(--drawer-detach) max-md:mt-auto max-md:mb-(--drawer-detach) max-md:max-h-[calc(100svh-(--spacing(16)))] max-md:min-h-[50svh] max-md:w-[calc(100%-2*var(--drawer-detach))] max-md:not-data-[status=open]:translate-y-full max-md:has-data-[status=open]:mb-[calc(var(--drawer-detach)+var(--drawer-stack))]',
      ],
    } satisfies Record<DrawerSide, string | string[]>,
  },
  defaultVariants: {
    side: 'right-bottom',
  },
});

interface DrawerContentProps
  extends React.ComponentProps<typeof Modal.Content> {
  side?: DrawerSide;
}

const DrawerContent = ({
  className,
  children,
  side = 'right-bottom',
  ...props
}: DrawerContentProps) => {
  return (
    <Modal.Content
      data-side={side}
      className={cn(drawerContentStyle({ side }), className)}
      {...props}
    >
      {children}
    </Modal.Content>
  );
};

const DrawerTrigger = Modal.Trigger;

const DrawerClose = Modal.Close;

const DrawerTitle = ({
  children,
  className,
  ...props
}: React.ComponentProps<typeof Modal.Title>) => {
  return (
    <Modal.Title className={cn('font-semibold', className)} {...props}>
      {children}
    </Modal.Title>
  );
};

const DrawerDescription = ({
  children,
  className,
  ...props
}: React.ComponentProps<typeof Modal.Description>) => {
  return (
    <Modal.Description className={cn('pb-2', className)} {...props}>
      {children}
    </Modal.Description>
  );
};

const DrawerBleed = ({
  className,
  ...props
}: React.ComponentPropsWithRef<'div'>) => {
  return <div className={cn('-mx-(--drawer-p)', className)} {...props} />;
};

const DrawerHeader = ({
  className,
  children,
  ...props
}: React.ComponentPropsWithRef<'div'>) => {
  return (
    <DrawerBleed
      className={cn(
        'sticky top-0 z-10 mb-(--drawer-p) border-border border-b bg-background p-(--drawer-p)',
        className
      )}
      {...props}
      data-drawer-header=""
    >
      {children}
    </DrawerBleed>
  );
};

const DrawerActions = ({
  className,
  children,
  ...props
}: React.ComponentPropsWithRef<'div'>) => {
  return (
    <DrawerBleed
      className={cn(
        'sticky bottom-0 mt-auto flex gap-(--drawer-p) border-border border-t bg-background p-(--drawer-p)',
        className
      )}
      {...props}
      data-drawer-actions=""
    >
      {children}
    </DrawerBleed>
  );
};

const CompoundDrawer = Object.assign(Drawer, {
  Content: DrawerContent,
  Header: DrawerHeader,
  Title: DrawerTitle,
  Description: DrawerDescription,
  Actions: DrawerActions,
  Bleed: DrawerBleed,
  Trigger: DrawerTrigger,
  Close: DrawerClose,
});

export { CompoundDrawer as Drawer };

Features

  • Edge Placement: Anchors to any edge via side, with responsive desktop-panel / mobile-sheet pairings built in
  • Radius-aware: Detachment and corner radius derive from the --radius dial — flush and square at --radius: 0, floating and rounded as roundness increases
  • Stackable: Drawers open on top of one another using the native dialog top-layer; covered drawers recede and their backdrops don’t compound
  • Focus Management: Automatically traps focus within the dialog (via Modal)
  • Controlled & Uncontrolled: Supports both controlled and uncontrolled modes
  • Customizable Actions: Built-in support for common dialog actions

Anatomy


          <Drawer>
  <Drawer.Trigger />
  <Drawer.Content>
    <Drawer.Header>
      <Drawer.Title />
    </Drawer.Header>
    <Drawer.Description />
    <Drawer.Actions>
      <Drawer.Close />
    </Drawer.Actions>
  </Drawer.Content>
</Drawer>
        

API Reference

Drawer

Extends the Modal component.

Drawer.Content

Extends the Modal.Content component.

PropTypeDefaultDescription
side"left" | "right" | "bottom" | "left-bottom" | "right-bottom""right-bottom"Edge the drawer anchors to.

Single-edge values (left, right, bottom) pin to that edge at every breakpoint. Hyphenated values are {desktop}-{mobile}: right-bottom is a right-hand panel on desktop that becomes a bottom sheet on mobile.

Detachment and corner radius are not props — they derive from the global --radius dial. At --radius: 0 the drawer is flush to the edge and square; as roundness increases it floats away from the edge with matching rounded corners.

Drawer.Trigger

Extends the Modal.Trigger component.

Drawer.Close

Extends the Modal.Close component.

Drawer.Title

Extends the Modal.Title component.

Drawer.Description

Extends the Modal.Description component.

Drawer.Header

Extends the div element.

Drawer.Actions

Extends the div element.

Examples

Simple

import { Button } from '@/components/button';
import { Drawer } from '@/components/drawer';

const DrawerPreview = () => {
  return (
    <Drawer>
      <Drawer.Trigger asChild>
        <Button>Open Drawer</Button>
      </Drawer.Trigger>
      <Drawer.Content>
        <Drawer.Header>
          <Drawer.Title>Drawer Title</Drawer.Title>
        </Drawer.Header>
        <Drawer.Description>Drawer content goes here.</Drawer.Description>
        <Drawer.Actions className="flex gap-2">
          <Drawer.Close asChild>
            <Button variant="primary" className="grow">
              Submit
            </Button>
          </Drawer.Close>
          <Drawer.Close asChild>
            <Button className="grow" variant="outline">
              Close
            </Button>
          </Drawer.Close>
        </Drawer.Actions>
      </Drawer.Content>
    </Drawer>
  );
};

export default DrawerPreview;

Tall Content

import { Button } from '@/components/button';
import { Drawer } from '@/components/drawer';

const DrawerTallContent = () => {
  return (
    <Drawer>
      <Drawer.Trigger asChild>
        <Button>Open Drawer</Button>
      </Drawer.Trigger>
      <Drawer.Content>
        <Drawer.Header>
          <Drawer.Title>Drawer Title</Drawer.Title>
        </Drawer.Header>
        {Array(24)
          .fill(null)
          .map((_, index) => (
            <p key={index} className="mb-4">
              This is paragraph {index + 1}.
            </p>
          ))}
        <Drawer.Actions className="flex gap-2">
          <Drawer.Close asChild>
            <Button className="grow">Submit</Button>
          </Drawer.Close>
          <Drawer.Close asChild>
            <Button className="grow" variant="outline">
              Close
            </Button>
          </Drawer.Close>
        </Drawer.Actions>
      </Drawer.Content>
    </Drawer>
  );
};

export default DrawerTallContent;

Controlled

import { useState } from 'react';

import { Button } from '@/components/button';
import { Drawer } from '@/components/drawer';

const DrawerControlled = () => {
  const [open, setOpen] = useState(false);
  const [isSubmitting, setIsSubmitting] = useState(false);

  const handleSubmit = () => {
    setIsSubmitting(true);

    setTimeout(() => {
      setIsSubmitting(false);
      setOpen(false);
    }, 3000);
  };

  const handleCancel = () => {
    setOpen(false);
  };

  return (
    <Drawer
      open={open}
      onOpenChange={(isOpen) => setOpen(isSubmitting ? true : isOpen)}
    >
      <Button onClick={() => setOpen(true)}>Open Drawer</Button>
      <Drawer.Content inert={isSubmitting}>
        <Drawer.Header>
          <Drawer.Title>Drawer Title</Drawer.Title>
        </Drawer.Header>
        <p>Drawer dangerous content goes here.</p>
        <Drawer.Actions>
          <Button
            className="grow"
            variant="destructive"
            isLoading={isSubmitting}
            onClick={handleSubmit}
            disabled={isSubmitting}
          >
            Delete Everything
          </Button>
          <Button
            className="grow"
            variant="outline"
            onClick={handleCancel}
            disabled={isSubmitting}
          >
            I&apos;m not sure
          </Button>
        </Drawer.Actions>
      </Drawer.Content>
    </Drawer>
  );
};

export default DrawerControlled;

Placement

import { useState } from 'react';

import { Button } from '@/components/button';
import { Drawer } from '@/components/drawer';

const SIDES = [
  'left',
  'right',
  'bottom',
  'left-bottom',
  'right-bottom',
] as const;

const DrawerSide = () => {
  const [side, setSide] = useState<(typeof SIDES)[number]>('right-bottom');
  const [open, setOpen] = useState(false);

  return (
    <div className="flex flex-wrap justify-center gap-2">
      {SIDES.map((value) => (
        <Button
          key={value}
          variant="outline"
          onClick={() => {
            setSide(value);
            setOpen(true);
          }}
        >
          {value}
        </Button>
      ))}

      <Drawer open={open} onOpenChange={setOpen}>
        <Drawer.Content side={side}>
          <Drawer.Header>
            <Drawer.Title>side=&quot;{side}&quot;</Drawer.Title>
          </Drawer.Header>
          <Drawer.Description>
            Hyphenated values pick a desktop edge and fall back to a bottom
            sheet on mobile — resize the viewport to see the switch.
          </Drawer.Description>
          <Drawer.Actions>
            <Drawer.Close asChild>
              <Button className="grow">Close</Button>
            </Drawer.Close>
          </Drawer.Actions>
        </Drawer.Content>
      </Drawer>
    </div>
  );
};

export default DrawerSide;

Stacked

import { Button } from '@/components/button';
import { Drawer } from '@/components/drawer';

const NestedDrawer = ({ depth }: { depth: number }) => {
  return (
    <Drawer>
      <Drawer.Trigger asChild>
        <Button variant="outline">Open Drawer {depth}</Button>
      </Drawer.Trigger>
      <Drawer.Content>
        <Drawer.Header>
          <Drawer.Title>Drawer {depth}</Drawer.Title>
        </Drawer.Header>
        <Drawer.Description>
          Each drawer recedes behind the next, only the topmost backdrop dims
          the page, and Escape closes one level at a time.
        </Drawer.Description>

        <NestedDrawer depth={depth + 1} />

        <Drawer.Actions>
          <Drawer.Close asChild>
            <Button className="grow" variant="outline">
              Close
            </Button>
          </Drawer.Close>
        </Drawer.Actions>
      </Drawer.Content>
    </Drawer>
  );
};

const DrawerStacked = () => {
  return <NestedDrawer depth={1} />;
};

export default DrawerStacked;

Best Practices

  1. Placement: Use right-bottom (the default) or left-bottom for content panels so they read as a side panel on desktop and a reachable sheet on mobile. Reserve single-edge values for cases that should not switch (e.g. a left nav drawer on every breakpoint).

  2. Stacking: Stacking works, but keep it shallow. A drawer that spawns another drawer is best reserved for a genuine sub-flow; for linear steps, replace the content of a single drawer instead.

  3. Content: Always include a Drawer.Title so the dialog has an accessible name. Keep content focused and use descriptive action labels (avoid “OK/Cancel”).

Previous

Divider

Next

Field