Drawer
A dialog that slides in from a logical edge, so it mirrors in Arabic with no code change. A swipe or the x closes it.
Drawer
client - size not measured yetA dialog that slides in from a logical edge, so it mirrors in Arabic with no code change. A swipe or the x closes it.
Try it
The code
import { Button } from 'topclassui/button'
import {
<DrawerRoot>
<DrawerTrigger render={<Button variant="outline">Filters</Button>} />
<DrawerPopup>
<DrawerHeader>
<DrawerTitle>Filters</DrawerTitle>
<DrawerDescription>Narrow the list of projects.</DrawerDescription>
</DrawerHeader>
<DrawerBody className="grid content-start gap-2.5">
<p>Status</p>
<p>Owner</p>
<p>Updated</p>
</DrawerBody>
<DrawerFooter>
<DrawerClose render={<Button variant="outline">Reset</Button>} />
<DrawerClose render={<Button>Apply</Button>} />
</DrawerFooter>
</DrawerPopup>
</DrawerRoot>placement is logical: start opens from the left in English and from the right in Arabic. Never left or right.
Every state
5 statesImport from the package
import { DrawerRoot, DrawerBody, DrawerClose, DrawerDescription, DrawerFooter, DrawerHeader, DrawerPopup, DrawerTitle, DrawerTrigger } from 'topclassui/drawer'Install once:npm i topclassui@beta zod
Examples
- For a panel that slides in from an edge of the screen: filters, navigation, a list to pick from on a phone.
placementonDrawerRoot:end(the default),start,toporbottom.sizeonDrawerRootis the widest astartorendsheet grows: 256, 288, 320, 384 or 480 px. Top and bottom sheets span the screen and grow to nine tenths of its height.- A swipe toward the sheet's own edge closes it once it is past half of the sheet. A finger starts anywhere on the sheet; a mouse starts on the header.
DrawerBodyscrolls and the header and the footer stay put. Start with aDrawerHeader, which keeps the title clear of the x.- On a phone the sheet pads the notch and the home indicator, when the viewport meta has
viewport-fit=cover.
import { Button } from 'topclassui/button'
import {
DrawerBody, DrawerClose, DrawerDescription, DrawerFooter, DrawerHeader, DrawerPopup, DrawerRoot, DrawerTitle,
DrawerTrigger,
} from 'topclassui/drawer'
/** The default: a sheet at the inline end, with a header, a body and a footer. It always ends
* with the x. Each label sits inside the Button passed to render, so a server render keeps it whole. */
export default function Demo() {
return (
<DrawerRoot>
<DrawerTrigger render={<Button variant="outline">Filters</Button>} />
<DrawerPopup>
<DrawerHeader>
<DrawerTitle>Filters</DrawerTitle>
<DrawerDescription>Narrow the list of projects.</DrawerDescription>
</DrawerHeader>
<DrawerBody className="grid content-start gap-2.5">
<p>Status</p>
<p>Owner</p>
<p>Updated</p>
</DrawerBody>
<DrawerFooter>
<DrawerClose render={<Button variant="outline">Reset</Button>} />
<DrawerClose render={<Button>Apply</Button>} />
</DrawerFooter>
</DrawerPopup>
</DrawerRoot>
)
}
import { Button } from 'topclassui/button'
import { DrawerBody, DrawerHeader, DrawerPopup, DrawerRoot, DrawerTitle, DrawerTrigger } from 'topclassui/drawer'
const LINKS = [['Documentation', '/docs'], ['Components', '/components'], ['Changelog', '/changelog']] as const
/** placement="start": the navigation of a phone. It opens from the left in LTR and from the
* right in RTL, and a swipe toward that edge closes it. Each link is a real anchor. */
export default function Demo() {
return (
<DrawerRoot placement="start">
<DrawerTrigger render={<Button variant="ghost">Menu</Button>} />
<DrawerPopup>
<DrawerHeader>
<DrawerTitle>Browse</DrawerTitle>
</DrawerHeader>
<DrawerBody className="grid content-start gap-1">
{LINKS.map(([label, href]) => (
<Button key={href} variant="ghost" tone="neutral" fullWidth className="justify-start" render={<a href={href} />}>
{label}
</Button>
))}
</DrawerBody>
</DrawerPopup>
</DrawerRoot>
)
}
import { Button } from 'topclassui/button'
import { DrawerBody, DrawerClose, DrawerHeader, DrawerPopup, DrawerRoot, DrawerTitle, DrawerTrigger } from 'topclassui/drawer'
const COUNTRIES = [
'Jordan', 'Egypt', 'Morocco', 'Tunisia', 'Algeria', 'Lebanon', 'Iraq', 'Kuwait', 'Qatar', 'Bahrain', 'Oman', 'Yemen',
'Sudan', 'Libya', 'Palestine', 'Syria', 'Mauritania', 'Somalia', 'Djibouti', 'Comoros', 'Turkey', 'Cyprus', 'Greece',
'Italy',
]
/** placement="bottom": the sheet of a phone. It starts with a grabber, grows to nine tenths of
* the screen and no further, and its list scrolls inside it. A swipe down closes it; so does the x. */
export default function Demo() {
return (
<DrawerRoot placement="bottom">
<DrawerTrigger render={<Button variant="outline">Country</Button>} />
<DrawerPopup>
<DrawerHeader>
<DrawerTitle>Choose a country</DrawerTitle>
</DrawerHeader>
<DrawerBody className="grid content-start gap-1">
{COUNTRIES.map((country) => (
<DrawerClose
key={country}
render={<Button variant="ghost" tone="neutral" fullWidth className="justify-start">{country}</Button>}
/>
))}
</DrawerBody>
</DrawerPopup>
</DrawerRoot>
)
}
import { Button } from 'topclassui/button'
import {
DrawerBody, DrawerClose, DrawerDescription, DrawerFooter, DrawerHeader, DrawerPopup, DrawerRoot, DrawerTitle,
DrawerTrigger,
} from 'topclassui/drawer'
/** placement="top": a sheet that comes down from the top edge. A swipe up closes it. */
export default function Demo() {
return (
<DrawerRoot placement="top">
<DrawerTrigger render={<Button variant="soft">What is new</Button>} />
<DrawerPopup>
<DrawerHeader>
<DrawerTitle>Version 2.4</DrawerTitle>
<DrawerDescription>Released this week.</DrawerDescription>
</DrawerHeader>
<DrawerBody>Tables can now pin their first column, and every menu takes a link.</DrawerBody>
<DrawerFooter>
<DrawerClose render={<Button>Got it</Button>} />
</DrawerFooter>
</DrawerPopup>
</DrawerRoot>
)
}
import { Button } from 'topclassui/button'
import {
DrawerBody, DrawerClose, DrawerFooter, DrawerHeader, DrawerPopup, DrawerRoot, DrawerTitle, DrawerTrigger,
} from 'topclassui/drawer'
import type { DrawerRootProps } from 'topclassui/drawer'
const WIDTHS: Array<[NonNullable<DrawerRootProps['size']>, string]> = [
['xs', '256 px'],
['sm', '288 px'],
['md', '320 px'],
['lg', '384 px'],
['xl', '480 px'],
]
/** The five widths of a sheet at the start or the end, and custom, which sets none: its className
* gives the sheet one. Each is the widest it grows; on a narrower screen every one fills it. */
export default function Demo() {
return (
<div className="flex flex-wrap items-center gap-3">
{WIDTHS.map(([size, width]) => (
<DrawerRoot key={size} size={size}>
<DrawerTrigger render={<Button variant="outline">{size}</Button>} />
<DrawerPopup>
<DrawerHeader>
<DrawerTitle>Size {size}</DrawerTitle>
</DrawerHeader>
<DrawerBody>This sheet grows to {width} and no wider.</DrawerBody>
<DrawerFooter>
<DrawerClose render={<Button>Done</Button>} />
</DrawerFooter>
</DrawerPopup>
</DrawerRoot>
))}
<DrawerRoot size="custom">
<DrawerTrigger render={<Button variant="outline">custom</Button>} />
<DrawerPopup className="max-w-xl">
<DrawerHeader>
<DrawerTitle>Size custom</DrawerTitle>
</DrawerHeader>
<DrawerBody>This sheet takes its width from the className it was given.</DrawerBody>
<DrawerFooter>
<DrawerClose render={<Button>Done</Button>} />
</DrawerFooter>
</DrawerPopup>
</DrawerRoot>
</div>
)
}
import { Button } from 'topclassui/button'
import { Input } from 'topclassui/input'
import {
DrawerBody, DrawerClose, DrawerFooter, DrawerHeader, DrawerPopup, DrawerRoot, DrawerTitle, DrawerTrigger,
} from 'topclassui/drawer'
/** A form in a bottom sheet. One form with display contents wraps the three bars, so the submit
* button in the footer belongs to it. On a phone the keyboard covers the lower half of the screen:
* a bottom sheet scrolls the field that has the focus clear of it. */
export default function Demo() {
return (
<DrawerRoot placement="bottom">
<DrawerTrigger render={<Button variant="outline">Invite</Button>} />
<DrawerPopup>
<form className="contents">
<DrawerHeader>
<DrawerTitle>Invite a teammate</DrawerTitle>
</DrawerHeader>
<DrawerBody className="grid content-start gap-1">
<label htmlFor="demo-invite-name" className="font-medium">Name</label>
<Input id="demo-invite-name" name="name" autoComplete="name" required fullWidth />
<label htmlFor="demo-invite-email" className="mt-3 font-medium">Email</label>
<Input id="demo-invite-email" name="email" type="email" autoComplete="email" required fullWidth />
</DrawerBody>
<DrawerFooter>
<DrawerClose render={<Button variant="outline">Cancel</Button>} />
<Button type="submit">Send</Button>
</DrawerFooter>
</form>
</DrawerPopup>
</DrawerRoot>
)
}
import { Button } from 'topclassui/button'
import {
DrawerBody, DrawerClose, DrawerDescription, DrawerFooter, DrawerHeader, DrawerPopup, DrawerRoot, DrawerTitle,
DrawerTrigger,
} from 'topclassui/drawer'
const ENTRIES = Array.from({ length: 60 }, (_, index) => index + 1)
/** Sixty rows: the body scrolls inside the sheet, and the header and the footer keep their
* places above and below it (R-10.5-1). At its end the page behind does not take over the scroll. */
export default function Demo() {
return (
<DrawerRoot>
<DrawerTrigger render={<Button variant="outline">Activity</Button>} />
<DrawerPopup>
<DrawerHeader>
<DrawerTitle>Activity</DrawerTitle>
<DrawerDescription>Sixty entries. Scroll to read them all.</DrawerDescription>
</DrawerHeader>
<DrawerBody className="grid content-start gap-3">
{ENTRIES.map((entry) => (
<p key={entry}>Entry {entry}. A file was changed in the shared folder.</p>
))}
</DrawerBody>
<DrawerFooter>
<DrawerClose render={<Button variant="outline">Mark all as read</Button>} />
<DrawerClose render={<Button>Done</Button>} />
</DrawerFooter>
</DrawerPopup>
</DrawerRoot>
)
}
import { Button } from 'topclassui/button'
import {
DrawerBody, DrawerClose, DrawerDescription, DrawerFooter, DrawerHeader, DrawerPopup, DrawerRoot, DrawerTitle,
DrawerTrigger,
} from 'topclassui/drawer'
const TITLE = 'Move the quarterly report, its eleven attachments and every comment thread to the shared archive of the finance team'
const WORD = 'a'.repeat(300)
const FILE = 'quarterly-report-final-v2-reviewed-by-legal-and-finance-and-signed-off-by-the-board.pdf'
/** Hostile content: a title of many lines, a word of 300 characters and three long actions.
* The title wraps clear of the x, the word breaks inside the body, and a long action wraps onto
* two lines. Where the bars leave the body no room, the sheet scrolls as one. */
export default function Demo() {
return (
<DrawerRoot>
<DrawerTrigger render={<Button variant="outline">Move to the archive</Button>} />
<DrawerPopup>
<DrawerHeader>
<DrawerTitle>{TITLE}</DrawerTitle>
<DrawerDescription>{FILE}</DrawerDescription>
</DrawerHeader>
<DrawerBody>{WORD}</DrawerBody>
<DrawerFooter>
<DrawerClose render={<Button variant="outline">Keep it where it is</Button>} />
<DrawerClose render={<Button variant="outline">Move a copy and keep the original</Button>} />
<DrawerClose render={<Button>Move everything to the archive</Button>} />
</DrawerFooter>
</DrawerPopup>
</DrawerRoot>
)
}
import { Button } from 'topclassui/button'
import {
DrawerBody, DrawerClose, DrawerFooter, DrawerHeader, DrawerPopup, DrawerRoot, DrawerTitle, DrawerTrigger,
} from 'topclassui/drawer'
/** modal={false}: no scrim, and the page beside the sheet stays in use. It scrolls, its buttons
* take a press, and a screen reader still reads it. Focus is not held: Tab past the last control,
* or a press anywhere outside, closes the sheet. */
export default function Demo() {
return (
<div className="flex flex-wrap items-center gap-3">
<DrawerRoot modal={false} size="xs">
<DrawerTrigger render={<Button variant="outline">Outline</Button>} />
<DrawerPopup>
<DrawerHeader>
<DrawerTitle>Outline</DrawerTitle>
</DrawerHeader>
<DrawerBody>The page stays live beside this panel, so you can keep working in it.</DrawerBody>
<DrawerFooter>
<DrawerClose render={<Button>Done</Button>} />
</DrawerFooter>
</DrawerPopup>
</DrawerRoot>
<Button variant="ghost">Behind the panel</Button>
</div>
)
}
import { Button } from 'topclassui/button'
import {
DrawerBody, DrawerClose, DrawerFooter, DrawerHeader, DrawerPopup, DrawerRoot, DrawerTitle, DrawerTrigger,
} from 'topclassui/drawer'
/** modal="trap-focus": no scrim, and the pointer keeps the page beside the sheet: it scrolls and
* its buttons take a press. The keyboard and a screen reader stay in the sheet: Tab goes round
* inside it and the page is hidden from them until it closes. */
export default function Demo() {
return (
<div className="flex flex-wrap items-center gap-3">
<DrawerRoot modal="trap-focus" size="xs">
<DrawerTrigger render={<Button variant="outline">Inspect</Button>} />
<DrawerPopup>
<DrawerHeader>
<DrawerTitle>Inspector</DrawerTitle>
</DrawerHeader>
<DrawerBody>Point at anything on the page beside this panel. The keyboard stays in it.</DrawerBody>
<DrawerFooter>
<DrawerClose render={<Button>Done</Button>} />
</DrawerFooter>
</DrawerPopup>
</DrawerRoot>
<Button variant="ghost">Behind the panel</Button>
</div>
)
}
import { TopClassProvider } from 'topclassui/provider'
import { Button } from 'topclassui/button'
import {
DrawerBody, DrawerClose, DrawerDescription, DrawerFooter, DrawerHeader, DrawerPopup, DrawerRoot, DrawerTitle,
DrawerTrigger,
} from 'topclassui/drawer'
/** Arabic, inside TopClassProvider as the root layout would mount it. The sheet is portaled into
* the body, so it takes its direction and its language from the provider: start opens from the
* right and end from the left, and each closes by a swipe toward its own edge, on an English page too. */
export default function Demo() {
return (
<TopClassProvider direction="rtl" locale="ar">
<div dir="rtl" lang="ar" className="flex flex-wrap items-center gap-3">
<DrawerRoot placement="start">
<DrawerTrigger render={<Button variant="ghost">القائمة</Button>} />
<DrawerPopup>
<DrawerHeader>
<DrawerTitle>تصفّح</DrawerTitle>
</DrawerHeader>
<DrawerBody>الوثائق والمكوّنات وسجل التغييرات.</DrawerBody>
</DrawerPopup>
</DrawerRoot>
<DrawerRoot>
<DrawerTrigger render={<Button variant="outline">التصفية</Button>} />
<DrawerPopup>
<DrawerHeader>
<DrawerTitle>عوامل التصفية</DrawerTitle>
<DrawerDescription>حدّد المشاريع التي تريد عرضها.</DrawerDescription>
</DrawerHeader>
<DrawerBody>الحالة والمالك وتاريخ التحديث.</DrawerBody>
<DrawerFooter>
<DrawerClose render={<Button variant="outline">إعادة ضبط</Button>} />
<DrawerClose render={<Button>تطبيق</Button>} />
</DrawerFooter>
</DrawerPopup>
</DrawerRoot>
</div>
</TopClassProvider>
)
}
Props
DrawerRoot
DrawerBody
No props of its own: it takes the attributes of the element it renders.
DrawerClose
No props of its own: it takes the attributes of the element it renders.
DrawerDescription
No props of its own: it takes the attributes of the element it renders.
DrawerFooter
No props of its own: it takes the attributes of the element it renders.
DrawerHeader
No props of its own: it takes the attributes of the element it renders.
DrawerPopup
No props of its own: it takes the attributes of the element it renders.
DrawerTitle
No props of its own: it takes the attributes of the element it renders.
DrawerTrigger
No props of its own: it takes the attributes of the element it renders.
Keyboard and accessibility
- Every sheet ends with an x that nothing removes: a swipe is a drag, and a drag needs a second way (WCAG 2.5.7).
DrawerTitlenames the sheet. With no title, passaria-labeltoDrawerPopup.- While
DrawerBodyscrolls it is a tab stop, so the keyboard reaches a long text with no control in it.
Right to left
start opens from the right and end from the left, and each closes by a swipe toward its own edge. Mount a TopClassProvider: without one the swipe follows left to right.
Server and client
A client component: it renders on the server and hydrates in the browser.
Files: index.tsx, client.tsx.
On the chip: client - size not measured yet.