Agents (llms.txt)

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 detached>
        <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 = 'right' | 'left' | 'top' | 'bottom' | 'responsive';

interface DrawerContentProps
  extends React.ComponentProps<typeof Modal.Content> {
  /**
   * Edge the drawer slides in from.
   *
   * `'responsive'` (default) = right on desktop, bottom on mobile (CK canon).
   * Explicit sides override responsive behavior on both viewports.
   */
  side?: DrawerSide;
  /**
   * Float the drawer with a gap from the viewport edge on desktop. All four
   * corners get the family 20px radius. Mobile stays edge-anchored.
   */
  detached?: boolean;
}

const drawerContentStyle = cva({
  base: [
    '[--drawer-p:--spacing(4)]',
    // No border (CK sheets have shadow only); CK modal shadow.
    'overflow-x-hidden! flex max-w-screen flex-col overflow-y-auto bg-surface-raised p-(--drawer-p) shadow-[var(--shadow-modal)] *: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',
    // No default backdrop blur (CK ships --ck-overlay-backdrop-filter: none).
    'backdrop:bg-(--color-overlay) not-data-[status=open]:backdrop:opacity-0',
    // animation — exact ConnectKit timings:
    //  IN  : 300ms spring overshoot (cubic-bezier(0.15, 1.15, 0.6, 1))
    //  OUT : 130ms plain ease
    'transition-transform ease-(--ease-spring) not-data-[status=open]:ease-(--ease)',
    'backdrop:transition-opacity backdrop:ease-out',
    'motion-reduce:transition-none motion-reduce:backdrop:transition-none',
    'duration-(--duration-sheet-in) not-data-[status=open]:duration-(--duration-sheet-out) not-data-[status=open]:backdrop:delay-100 not-data-[status=open]:backdrop:duration-(--duration-sheet-out)',
  ],
  variants: {
    // Per-side translate + corner-radius rules. Mobile mirrors `responsive`'s
    // bottom-sheet behavior unless an explicit horizontal side is specified.
    side: {
      responsive: [
        'mx-auto md:mr-0 md:h-full md:max-h-screen md:w-full md:max-w-lg',
        'md:not-data-[status=open]:translate-x-full',
        'min-h-[50svh] max-md:mb-0 max-md:max-h-[calc(100svh-(--spacing(16)))] max-md:w-full',
        'max-md:not-data-[status=open]:translate-y-full max-md:rounded-t-[1.875rem]',
      ],
      right: [
        'mr-0 ml-auto h-full max-h-screen w-full md:max-w-lg',
        'not-data-[status=open]:translate-x-full',
      ],
      left: [
        'mr-auto ml-0 h-full max-h-screen w-full md:max-w-lg',
        'not-data-[status=open]:-translate-x-full',
      ],
      top: [
        'mx-auto mt-0 mb-auto h-auto max-h-[80vh] w-full md:max-w-lg',
        'rounded-b-[1.875rem]',
        'not-data-[status=open]:-translate-y-full',
      ],
      bottom: [
        'mx-auto mt-auto mb-0 h-auto max-h-[80vh] w-full md:max-w-lg',
        'rounded-t-[1.875rem]',
        'not-data-[status=open]:translate-y-full',
      ],
    },
    detached: {
      true: '',
      false: '',
    },
  },
  // Per-side detached overrides. `m-3` shorthand would clobber the side
  // anchor (e.g. `md:mr-0` → wrongly becomes margin: 12px on all sides),
  // left-aligning the drawer. Each side keeps its anchor margin and only
  // adds the perpendicular gap + opposite-edge `auto` to push correctly.
  // Mobile stays untouched — sheets always meet the bottom edge.
  compoundVariants: [
    {
      side: 'responsive',
      detached: true,
      class:
        'md:my-3 md:mr-3 md:ml-auto md:max-h-[calc(100vh-1.5rem)] md:rounded-[1.25rem]',
    },
    {
      side: 'right',
      detached: true,
      class: 'my-3 mr-3 ml-auto max-h-[calc(100vh-1.5rem)] rounded-[1.25rem]',
    },
    {
      side: 'left',
      detached: true,
      class: 'my-3 mr-auto ml-3 max-h-[calc(100vh-1.5rem)] rounded-[1.25rem]',
    },
    {
      side: 'top',
      detached: true,
      class: 'mx-3 mt-3 mb-auto max-h-[calc(80vh-1.5rem)] rounded-[1.25rem]',
    },
    {
      side: 'bottom',
      detached: true,
      class: 'mx-3 mt-auto mb-3 max-h-[calc(80vh-1.5rem)] rounded-[1.25rem]',
    },
  ],
  defaultVariants: { side: 'responsive', detached: false },
});

const DrawerContent = ({
  className,
  children,
  side = 'responsive',
  detached = false,
  ...props
}: DrawerContentProps) => {
  return (
    <Modal.Content
      // Drawer drives its own translate-based slide; opt out of Modal's
      // fade+scale family motion so the two don't compound.
      data-motion=""
      data-drawer-side={side}
      data-drawer-detached={detached || undefined}
      className={drawerContentStyle({ side, detached, 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(
        // Inherit the drawer body bg — keeps header / content / footer
        // visually a single continuous surface (no two-tone mismatch).
        'sticky top-0 z-10 mb-(--drawer-p) bg-surface-raised 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-2 bg-surface-raised 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 type { DrawerContentProps, DrawerProps, DrawerSide };
export { CompoundDrawer as Drawer };

Server component. No 'use client' — thin styling wrapper over the client Modal; no hooks, context, or own event handlers.

Features

  • Modal Overlay: Creates an accessible modal dialog with backdrop
  • Focus Management: Automatically traps focus within the dialog
  • Flexible Positioning: Center or top alignment options
  • 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.

Prop Default Type Description
side "responsive" "responsive""right""left""top""bottom" Edge the drawer slides in from. 'responsive' is right on desktop, bottom on mobile (ConnectKit canonical). Explicit sides override on both viewports.
detached false boolean Float the drawer with a gap from the viewport edge on desktop. All four corners get the family 20px radius. Mobile stays edge-anchored.

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 detached>
        <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;

Sides + detached

import { useState } from 'react';

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

const SIDES = ['responsive', 'right', 'left', 'top', 'bottom'] as const;
type Side = (typeof SIDES)[number];

const DrawerSidesPreview = () => {
  const [side, setSide] = useState<Side>('responsive');
  const [detached, setDetached] = useState(false);

  return (
    <div className="flex flex-col items-start gap-4">
      <div className="flex flex-wrap items-center gap-2">
        {SIDES.map((s) => (
          <Button
            key={s}
            size="sm"
            variant={side === s ? 'primary' : 'brandSoft'}
            onClick={() => setSide(s)}
          >
            {s}
          </Button>
        ))}
        <Button
          size="sm"
          variant={detached ? 'primary' : 'brandSoft'}
          onClick={() => setDetached((d) => !d)}
        >
          detached: {detached ? 'on' : 'off'}
        </Button>
      </div>

      <Drawer>
        <Drawer.Trigger asChild>
          <Button>
            Open ({side}
            {detached ? ' / detached' : ''})
          </Button>
        </Drawer.Trigger>
        <Drawer.Content side={side} detached={detached}>
          <Drawer.Header>
            <Drawer.Title>
              side="{side}"{detached && ' detached'}
            </Drawer.Title>
          </Drawer.Header>
          <Drawer.Description>
            Try resizing the viewport — <strong>responsive</strong> is right on
            desktop and bottom on mobile. Explicit sides override on both
            viewports. <strong>detached</strong> insets the drawer with a 12px
            gap on desktop only.
          </Drawer.Description>
          <Drawer.Actions>
            <Drawer.Close asChild>
              <Button variant="brandSoft" className="grow">
                Close
              </Button>
            </Drawer.Close>
          </Drawer.Actions>
        </Drawer.Content>
      </Drawer>
    </div>
  );
};

export default DrawerSidesPreview;

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 className="flex gap-2">
          <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;

Best Practices

  1. Content Structure:

    • Always include a clear title that describes the purpose
    • Keep content concise and focused
    • Use appropriate action labels (avoid “OK/Cancel”)
  2. Performance:

    • Lazy load dialog content if needed
    • Consider using dynamic imports for heavy content
    • Clean up resources when dialog closes

Previous

Divider

Next

Empty State