Drawer#

The edge panel — navigation, filters, a cart — on the same native <dialog> machinery as Dialog: the top layer instead of a Portal, showModal() for focus trapping, Escape and focus restore, and a ::backdrop pseudo part. What is the drawer's own is the edge: the panel declares data-placement="start|end" from the logical pair, so a navigation drawer sits at the reading start in both directions and RTL mirrors for free.

Import#

TSX
import { Drawer } from '@sigx/zero/drawer';

Drawer is a compound: Drawer.Root, Drawer.Trigger, Drawer.Panel, Drawer.Title, Drawer.Close. It is also re-exported from the @sigx/zero root, together with drawerAnatomy, useDrawerContext and the DrawerPlacement type.

Usage#

TSX
import { component } from 'sigx';
import { Drawer } from '@sigx/zero/drawer';

const SiteNav = component(({ signal }) => {
    const state = signal({ open: false });

    return () => (
        <Drawer.Root model={() => state.open} label="Site navigation">
            <Drawer.Trigger color="neutral">Menu</Drawer.Trigger>
            <Drawer.Panel>
                <Drawer.Title>Navigation</Drawer.Title>
                <nav>…</nav>
                <Drawer.Close>Close</Drawer.Close>
            </Drawer.Panel>
        </Drawer.Root>
    );
});

model={() => state.open} binds the open state both ways. Leave the model off and pass defaultOpen to keep the state inside the component; openChange fires either way. See Models. Drawer.Root renders no element of its own — it only provides context — so the trigger and the panel can sit anywhere in the tree below it. The panel is rendered closed in place and lifted into the top layer when it opens; there is no Portal and no z-index to manage.

The end edge#

TSX
<Drawer.Root model={() => state.cart} placement="end" label="Shopping cart">

placement is the logical pair start (default) or end, stamped as data-placement on the panel. A recipe pins the panel with inset-inline-start: 0 / inset-inline-end: 0 — never left / right — and the layout mirrors under dir="rtl" with no further rule.

Inline (non-modal)#

TSX
<Drawer.Root model={() => state.filters} modal={false}>

modal={false} opens with show() instead of showModal(): the panel renders in flow, the page beside it stays interactive, and there is no backdrop. Clicks elsewhere are a non-event — an inline drawer is furniture that survives them — while Escape still closes it, under dismissible, through zero's dismiss layer. show() provides no focus restore either, so zero covers that itself on close.

Naming a drawer without a heading#

A drawer often has no visible heading. label on the root becomes the panel's aria-label whenever no Drawer.Title is rendered; while a title is present, the panel is labelled by it instead and label is not rendered. Give every drawer one or the other.

Or keep the title and hide it: Drawer.Title visuallyHidden stays in the accessibility tree — it still names the panel through aria-labelledby — and leaves the screen, which is the usual shape for a navigation sheet in an app shell:

TSX
<Drawer.Panel measure="xs">
    <Drawer.Title visuallyHidden>Navigation</Drawer.Title>
    <nav>…</nav>
</Drawer.Panel>

Panel width#

measure on Drawer.Panel sizes the panel from the design system's --measure-* ramp: xs, sm, md, lg, xl, prose or full. It is the same measure layout attribute Container uses — rendered as data-l-measure — not the size axis, which rides the trigger and cannot reach a panel that is not inside it. The measure is a cap: the panel fills the space it is given up to it — its container inline, the viewport as a modal sheet — so measure="full" is a full-screen sheet. Unset, each design system's own drawer width applies. Every shipped skin sizes the panel box-sizing: border-box, so its padding never pushes it past the measure.

Why it closed#

The close event follows openChange(false) once per close with a DrawerCloseDetail — { reason, value? } — exactly as on Dialog, without cancel: reason is close (a Drawer.Close), escape, backdrop (a scrim click) or programmatic (every close zero did not start). Drawer.Close value="…" comes back as value, and a close the parent refuses is not reported.

TSX
<Drawer.Root model={() => state.filters} onClose={(d) => { if (d.value === 'apply') apply(); }}>
    …
    <Drawer.Close value="apply">Apply filters</Drawer.Close>
</Drawer.Root>

Anatomy#

PartElementStatesFlagsNotes
triggerbuttonopen | closeddisabled, focus-visible, pressed, press-animatingaria-haspopup="dialog", aria-expanded, aria-controls. Carries the variant axes. asChild.
paneldialogopen | closed—data-placement from start | end; data-l-measure from measure; aria-labelledby while a Title renders, else aria-label from label.
backdroppseudoopen | closed—Renders no element on the web; projects onto panel::backdrop.
titleh2——Inside panel. Offers visuallyHidden (data-visually-hidden).
closebutton—disabled, focus-visible, pressed, press-animatingCloses the drawer. Inside panel. asChild.

Drawer's Root renders a fragment, so there is no root part. The trigger is the carrier part: the variant axes live on Drawer.Trigger, and a design system's axis rules for the scope anchor there. The panel is a top-layer sibling of the trigger, so colour styles the trigger, never the panel.

The backdrop is a real part that renders no element on the web: recipes style parts.backdrop and the compiler projects it onto [data-scope="drawer"][data-part="panel"]::backdrop, with states narrowing the panel. Modal and inline need no attribute of their own to tell apart — :modal is the platform's spelling of exactly that split, so a recipe writes &:modal for the trapped, scrimmed presentation and &:not(:modal) for the in-flow one. Both close and trigger publish press feedback. See The anatomy contract.

Props#

Drawer.Root#

PropTypeDefaultDescription
modelboolean—Two-way binding of the open state.
defaultOpenbooleanfalseInitial state when uncontrolled.
openChangeevent (open: boolean)—Fires whenever the open state changes.
closeevent (detail: DrawerCloseDetail)—Fires once per close, after openChange(false): { reason, value? }, reason one of 'close' | 'escape' | 'backdrop' | 'programmatic' (DrawerCloseReason).
modalbooleantrueshowModal() (focus trap, inert background, backdrop) or inline show().
dismissiblebooleantrueWhether Escape and, for a modal drawer, a scrim click close it.
placement'start' | 'end''start'Which reading edge the panel sits on; rendered as data-placement on the panel.
labelstring—Accessible name of the panel when no Drawer.Title renders.

Drawer.Trigger#

PropTypeDefaultDescription
disabledbooleanfalseRenders data-disabled.
color / size / variant / axes / modsdesign-system vocabulary—The variant axes for the scope, rendered on the trigger.
asChildbooleanfalseRender through the default slot, which receives the part bag.
classstring—Extra classes.

Drawer.Panel#

PropTypeDefaultDescription
measure'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'prose' | 'full'—Width cap from the --measure-* ramp, rendered as data-l-measure. Unset, the design system's own width.
classstring—Extra classes.

Drawer.Title#

PropTypeDefaultDescription
visuallyHiddenbooleanfalseOff screen, still the panel's accessible name; renders data-visually-hidden.
classstring—Extra classes.

Drawer.Close#

PropTypeDefaultDescription
valuestring—Reported as the close event's value; also rendered as the button's native value.
disabledbooleanfalseRenders data-disabled.
asChildbooleanfalseRender through the default slot.
classstring—Extra classes.

Keyboard and dismissal#

InteractionModalInline (modal={false})
EscapeCloses, via the native cancel event, when dismissible.Closes through zero's dismiss layer, when dismissible.
Scrim clickCloses when dismissible; only a pointer outside the panel's box counts, so a click on the panel's own padding does not.No backdrop; clicks elsewhere are not an event.
TabTrapped inside the panel by the platform.Moves freely between the panel and the page.
Focus on closeRestored by the platform.Restored by zero.

In the shipped design systems#

@sigx/zero-basic and @sigx/zero-daisyui both wire color (the eight recommended roles) and size (xs–xl) on the drawer trigger, in each skin's own button idiom. Neither wires a variant or any mods on the scope. The panel, backdrop, title and close parts are styled by every shipped design system, including the slide transition keyed on data-placement and the :modal scrim.

Dialog for a centred modal on the same machinery · Popover for an anchored, non-modal surface.