sdxc

[ @sdxc/ui ]

Drawer

A preset of Dialog docked flush against one physical edge of the viewport, sized to fill that edge, sliding into place from it on open and fading out with a short drift toward it on close.

Your cart

Shipping is on us on this order.

Felt desk mat

Charcoal · 90 × 40 cm

$45.00
Low-profile keycaps

Warm grey · ISO

$138.00
Coiled USB-C cable

Aviator · 1.5 m

$32.00
Subtotal$215.00
ShippingFree
Total$215.00
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/ui remix
import "@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

Bottom filters

Filter issues

Show only the issues that match every choice.

View code
<Button commandfor="filters" command="show-modal" variant="outline">
	<ListFilterIcon />
	Filters
</Button>

<Drawer id="filters" placement="bottom" aria-labelledby="filters-title">
	<Drawer.Header>
		<Drawer.Title id="filters-title">Filter issues</Drawer.Title>
		<Drawer.Description>Show only the issues that match every choice.</Drawer.Description>
	</Drawer.Header>
	<div mix={[vstack({ gap: 3, align: "start" })]}>
		<Checkbox name="assigned" defaultChecked>Assigned to me</Checkbox>
		<Checkbox name="open">Open only</Checkbox>
		<Checkbox name="priority">High priority</Checkbox>
	</div>
	<Drawer.Footer>
		<Button commandfor="filters" command="close">Apply filters</Button>
	</Drawer.Footer>
</Drawer>

Props

Read from the component's own types, so every prop, every default and every allowed value is listed.

PropTypeDescription
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

NameValuesWhat it decides
Placement"top" | "right" | "bottom" | "left"Physical edge of the viewport the panel docks against and slides in from.

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.