Zubeyr··ComponentsoverlaysDrawer
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-reactfrom npm. - →Pulls in
dialog. - →Files land in
components/dashboard.
Installing by hand? Add the dependencies, then copy the source code below.
pnpm add lucide-reactUsage
'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
'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,
)
}