Drawer
A preset of Dialog docked flush against one physical edge of the viewport, sized to fill that edge and sliding into place from it on open, back out on close.
View code
let lines: CartLine[] = CART_LINES.map((line) => ({ ...line }));
function changeQuantity(event: Event & { currentTarget: HTMLInputElement }) {
let line = lines.find((candidate) => candidate.id === event.currentTarget.dataset.line);
if (!line) return;
line.quantity = Math.max(1, Number(event.currentTarget.value) || 1);
void handle.update();
}
let totals = cartTotals(lines);
<Button commandfor="cart" command="show-modal">
<ShoppingCartIcon />
Cart · {totals.itemCount}
</Button>
<Drawer id="cart" placement="right" aria-labelledby="cart-title">
<Drawer.Header>
<Drawer.Title id="cart-title">Your cart</Drawer.Title>
<Drawer.Description>
{totals.freeShipping ? "Shipping is on us on this order." : "Spend $150 to get free shipping."}
</Drawer.Description>
</Drawer.Header>
<div
mix={[
vstack({ gap: 4, align: "stretch" }),
// The dock is as tall as the viewport, so everything between the header and the
// footer scrolls as one column — the checkout button never slides out of reach.
grow(),
minBs(0),
overflowY("auto"),
]}
>
{lines.map((line) => (
<Item key={line.id}>
<Item.Content>
<Item.Title>{line.name}</Item.Title>
<Item.Description>{line.variant}</Item.Description>
<div mix={[hstack({ gap: 2, align: "center", justify: "between" }), mbs(1)]}>
<NumberField.Group mix={[stepper()]}>
<NumberField.DecrementButton
command={NUMBER_FIELD_STEP_DOWN_COMMAND}
commandfor={`cart-quantity-${line.id}`}
aria-label={`One fewer ${line.name}`}
/>
<NumberField.Input
id={`cart-quantity-${line.id}`}
name={`quantity[${line.id}]`}
data-line={line.id}
min={1}
max={99}
value={String(line.quantity)}
aria-label={`Quantity of ${line.name}`}
mix={[on<HTMLInputElement, "input">("input", changeQuantity)]}
/>
<NumberField.IncrementButton
command={NUMBER_FIELD_STEP_UP_COMMAND}
commandfor={`cart-quantity-${line.id}`}
aria-label={`One more ${line.name}`}
/>
</NumberField.Group>
<span>{formatPrice(line.unitPrice * line.quantity)}</span>
</div>
</Item.Content>
</Item>
))}
<Separator />
<Text>Subtotal</Text>
<Text>{totals.subtotal}</Text>
<Text>Shipping</Text>
<Text>{totals.shipping}</Text>
<Text>Total</Text>
<Text>{totals.total}</Text>
</div>
<Drawer.Footer>
<Button commandfor="cart" command="close" variant="outline" color="neutral">
Keep shopping
</Button>
<Button commandfor="cart" command="close">Checkout · {totals.total}</Button>
</Drawer.Footer>
<Drawer.Close commandfor="cart" aria-label="Close the cart" />
</Drawer>Installation
An ordinary dependency: install it, import the theme once, and import the component where it is used.
npm add @sdxc/uiimport "@sdxc/ui/theme.css";Usage
import { Drawer } from "@sdxc/ui";Renders Dialog pinned to one edge of the viewport, animating a
transform off the native open attribute so @starting-style can
animate the close; a mix prop composes alongside this placement styling.
Examples
<Drawer id="filters" placement="bottom" aria-labelledby="filters-title">
<Drawer.Header>
<Drawer.Title id="filters-title">{t("filters.title")}</Drawer.Title>
</Drawer.Header>
<Drawer.Footer>
<Button commandfor="filters" command="close">{t("actions.apply")}</Button>
</Drawer.Footer>
</Drawer>Props
Read from the component's own types, so every prop, every default and every allowed value is listed.
| Prop | Type | Description |
|---|---|---|
placement? | "top" | "right" | "bottom" | "left" | Physical edge of the viewport to dock against and slide in from. Defaults to DEFAULT_PLACEMENT. |
Also accepts everything in Dialog.Props.
Types
| Name | Values | What it decides |
|---|---|---|
Placement | "top" | "right" | "bottom" | "left" | Physical edge of the viewport the panel docks against and slides in from. |