← Dashboard
Zubeyr··Componentsoverlays

Drawer

A side or bottom drawer for record details and quick edits: header with actions, scrolling body, sticky footer, focus trap, Escape and outside-click close, scroll lock and a bottom sheet on phones.

Live demo ↗

Installation

Run the shadcn CLI in a project that already has components.json.

pnpm dlx shadcn@latest add https://tomei.zubeyr.dev/ui/r/drawer.json
  • →Installs lucide-react from npm.
  • →Pulls in dialog.
  • →Files land in components/dashboard.

Installing by hand? Add the dependencies, then copy the source code below.

terminal.sh
pnpm add lucide-react

Usage

example.tsx
'use client'

import { useState } from 'react'
import { useForm, useWatch } from 'react-hook-form'

import { Button } from '@/registry/dashboard/components/button/button'
import {
  RecordForm,
  recordFormResolver,
  type FormSection,
  type FormValues,
} from '@/registry/dashboard/components/record-form/record-form'

import { Drawer } from '@/components/dashboard/drawer'

const sections: FormSection[] = [
  {
    id: 'contact',
    title: 'Contact',
    columns: 2,
    defaultOpen: true,
    fields: [
      { name: 'name', label: 'Name', type: 'text', required: true, span: 2 },
      { name: 'email', label: 'Email', type: 'email' },
      { name: 'phone', label: 'Phone', type: 'phone', defaultCountry: 'ET' },
      { name: 'stage', label: 'Stage', type: 'select', options: ['Lead', 'Customer', 'Partner'] },
      { name: 'since', label: 'Customer since', type: 'date', max: 'today' },
    ],
  },
]

export default function Demo() {
  const [open, setOpen] = useState(false)
  const form = useForm<FormValues>({
    defaultValues: {
      name: 'Selam Girma',
      email: 'selam@example.com',
      phone: '',
      stage: 'Customer',
      since: '',
    },
    resolver: recordFormResolver({ sections }),
  })
  const save = form.handleSubmit((values) => {
    form.reset(values)
    setOpen(false)
  })
  const name = useWatch({ control: form.control, name: 'name' })

  return (
    <>
      <Button onClick={() => setOpen(true)}>Open contact</Button>
      <Drawer
        open={open}
        onOpenChange={setOpen}
        title={String(name || 'Contact')}
        description="Customer · Addis Ababa"
        footer={
          <>
            <Button variant="ghost" onClick={() => setOpen(false)}>
              Close
            </Button>
            <Button variant="primary" disabled={!form.formState.isDirty} onClick={save}>
              Save changes
            </Button>
          </>
        }
      >
        <RecordForm form={form} sections={sections} layout="cards" searchable={false} />
      </Drawer>
    </>
  )
}

Source code

drawer.tsx
'use client'

import { useId } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'

import { cn } from '@/lib/utils'
import { useModal, usePresence } from '@/registry/dashboard/components/dialog/modal'

const sides = {
  right: {
    wrap: 'justify-end',
    panel: 'h-full w-full border-l sm:rounded-l-2xl',
    hidden: 'translate-x-full',
  },
  left: {
    wrap: 'justify-start',
    panel: 'h-full w-full border-r sm:rounded-r-2xl',
    hidden: '-translate-x-full',
  },
  bottom: {
    wrap: 'items-end',
    panel: 'max-h-[90svh] w-full rounded-t-2xl border-t',
    hidden: 'translate-y-full',
  },
}

const widths = { sm: 'sm:max-w-sm', md: 'sm:max-w-md', lg: 'sm:max-w-xl', xl: 'sm:max-w-3xl' }

export type DrawerProps = {
  open: boolean
  onOpenChange: (open: boolean) => void
  title: React.ReactNode
  description?: React.ReactNode
  actions?: React.ReactNode
  footer?: React.ReactNode
  side?: keyof typeof sides
  size?: keyof typeof widths
  dismissible?: boolean
  initialFocus?: React.RefObject<HTMLElement | null>
  closeLabel?: string
  className?: string
  children?: React.ReactNode
}

export function Drawer({
  open,
  onOpenChange,
  title,
  description,
  actions,
  footer,
  side = 'right',
  size = 'md',
  dismissible = true,
  initialFocus,
  closeLabel = 'Close',
  className,
  children,
}: DrawerProps) {
  const titleId = useId()
  const descriptionId = useId()
  const { present, visible } = usePresence(open, 220)
  const { panel } = useModal({
    open,
    onClose: () => onOpenChange(false),
    dismissible,
    initialFocus,
  })
  const s = sides[side]

  if (!present || typeof document === 'undefined') return null

  return createPortal(
    <div className={cn('fixed inset-0 z-[150] flex', s.wrap)}>
      <div
        aria-hidden
        className={cn(
          'absolute inset-0 bg-black/40 transition-opacity duration-200 motion-reduce:transition-none',
          visible ? 'opacity-100' : 'opacity-0',
        )}
        onPointerDown={() => dismissible && onOpenChange(false)}
      />
      <div
        ref={panel}
        role="dialog"
        aria-modal="true"
        aria-labelledby={titleId}
        aria-describedby={description ? descriptionId : undefined}
        tabIndex={-1}
        className={cn(
          'relative flex flex-col bg-card text-card-foreground shadow-(--shadow-overlay) transition-transform duration-200 ease-out outline-none motion-reduce:transition-none',
          s.panel,
          side !== 'bottom' && widths[size],
          visible ? 'translate-none' : s.hidden,
          className,
        )}
      >
        {side === 'bottom' && (
          <span aria-hidden className="mx-auto mt-2 h-1 w-10 shrink-0 rounded-full bg-muted" />
        )}
        <header className="flex items-start gap-3 border-b px-5 py-4">
          <div className="flex min-w-0 flex-1 flex-col gap-1">
            <h2 id={titleId} className="truncate text-base font-semibold">
              {title}
            </h2>
            {description && (
              <p id={descriptionId} className="text-sm text-muted-foreground">
                {description}
              </p>
            )}
          </div>
          {actions && <div className="flex shrink-0 items-center gap-1.5">{actions}</div>}
          <button
            type="button"
            aria-label={closeLabel}
            onClick={() => onOpenChange(false)}
            className="-mr-2 flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground [&_svg]:size-4"
          >
            <X />
          </button>
        </header>
        <div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">{children}</div>
        {footer && (
          <footer className="flex flex-wrap items-center justify-end gap-2 border-t px-5 py-3">
            {footer}
          </footer>
        )}
      </div>
    </div>,
    document.body,
  )
}