Playground
A panel on the native <dialog> that slides in from an edge of the viewport. As a modal (the default) the page behind it is inert, the browser traps focus, and Escape or a click on the backdrop closes it. With modal="false" the page stays usable, like a side panel. Open it with show() and close it with hide(), or let the server own it with open.
Examples
Open from Datastar
data-ref gives you the element as a signal, so any Datastar expression can call its methods.
Planets with rings, in the outer system.
<div>
<sb-button data-on:click="$_filters.show()">Filters</sb-button>
<sb-drawer data-ref:_filters heading="Filters" data-on:sb-close="$_picked = evt.detail.value || ''">
<p>Planets with rings, in the outer system.</p>
<sb-button slot="footer" variant="ghost" data-sb-close="reset">Reset</sb-button>
<sb-button slot="footer" data-sb-close="apply">Apply</sb-button>
</sb-drawer>
<p data-signals:_picked="''" data-show="$_picked" data-text="'You chose: ' + $_picked"></p>
</div>
Sides
side is the edge it slides in from: end (the default), start, top or bottom. start and end follow the text direction, so start is the left edge in left-to-right text and the right edge in right-to-left text. --sb-drawer-size is the width of a start or end drawer and the height of a top or bottom one.
<div style="display: flex; flex-wrap: wrap; gap: 8px">
<sb-button variant="outline" data-on:click="$_start.show()">Start</sb-button>
<sb-button variant="outline" data-on:click="$_end.show()">End</sb-button>
<sb-button variant="outline" data-on:click="$_top.show()">Top</sb-button>
<sb-button variant="outline" data-on:click="$_bottom.show()">Bottom</sb-button>
<sb-drawer data-ref:_start side="start" heading="Navigation">
<nav style="display: grid; gap: 8px"><a href="#">Bridge</a><a href="#">Engine room</a><a href="#">Cargo bay</a></nav>
</sb-drawer>
<sb-drawer data-ref:_end side="end" heading="Crew">Seven on board, two on shore leave.</sb-drawer>
<sb-drawer data-ref:_top side="top" heading="Alerts" style="--sb-drawer-size: 10rem">No alerts in this sector.</sb-drawer>
<sb-drawer data-ref:_bottom side="bottom" heading="Cargo" style="--sb-drawer-size: 14rem">
Twelve crates of ice, one of spare parts.
<sb-button slot="footer" data-sb-close>Done</sb-button>
</sb-drawer>
</div>
Next to the page
With modal="false" the drawer has no backdrop and doesn't trap focus: the page stays usable while it is open. Escape closes it while the focus is inside it.
<div data-signals:_fuel="40">
<sb-button data-on:click="$_tools.isOpen ? $_tools.hide() : $_tools.show()">Toggle tools</sb-button>
<sb-drawer data-ref:_tools modal="false" heading="Tools" style="--sb-drawer-size: 16rem">
<label>Fuel <input type="range" data-bind:_fuel></label>
<p data-text="'Fuel: ' + $_fuel + ' %'"></p>
</sb-drawer>
</div>
A custom header
The header slot replaces the heading. Whatever it holds names the drawer for screen readers.
<div>
<sb-button data-on:click="$_ship.show()">Ship details</sb-button>
<sb-drawer data-ref:_ship>
<span slot="header" style="display: flex; gap: 8px; align-items: center"><span aria-hidden="true">🚀</span><strong>Starbase One</strong></span>
Docked at bay 7. Next departure at 14:20.
</sb-drawer>
</div>
Inline
inline renders the panel in place, which is handy for docs and previews. An inline panel is always visible and has no close button.
<sb-drawer inline side="start" heading="Mission log" style="--sb-drawer-size: 16rem">Day 12. The rings of X-9 are in view.</sb-drawer>
Buttons that close it
Any element in the drawer with data-sb-close closes it and reports the attribute's value as detail.value (an empty data-sb-close reports the element's text). detail.reason says what closed it: action for these, button for the close button, escape, backdrop, or api for hide(). A data-sb-close inside a nested sb-drawer or sb-modal closes only that one.
A <form> in the body can't close the drawer with method="dialog": the <dialog> is in the component's shadow root. Give a submit button in the footer form="…" (the form's id), and close the drawer from the form's submit handler or let the server close it.
With commands
In a CQRS app the server owns "is the drawer open?", exactly like sb-modal's open. The button below stands in for the server: it sets the open attribute, like a re-render would, and three seconds later sends open="false". Close the drawer yourself before that and sb-close tells the "server", which then sends open="false" right away.
<div data-signals="{_srv: 'false'}">
<sb-button data-on:click="$_srv = 'true'; setTimeout(() => $_srv = 'false', 3000)">Server opens it for 3 s</sb-button>
<sb-drawer heading="Incoming transmission" data-attr:open="$_srv" data-preserve-attr="open"
data-on:sb-close="$_srv = 'false'">
The server opened this drawer and will close it. Press Escape to close it now.
</sb-drawer>
</div>
On a Starbase page it is one sb-close handler and one command:
<sb-drawer heading="Edit profile" open="{{ .Editing }}"
data-on:sb-close="@post('/cmd/edit-profile/close', {payload: {tabid: $tabid, ...evt.detail}})">
…
</sb-drawer>
The command clears the flag, and the next render sends open="false".
openon the first render opens the drawer on the first paint.- A changed
openwins:openopens the drawer,open="false"closes it, also one opened withshow(). Neither sendssb-openorsb-close: the server already knows. - The same markup again changes nothing. A morph never reopens a drawer the user closed.
- A removed
openattribute changes nothing either. Morphs also strip attributes that were only reflected, so to close it the server sendsopen="false".
show() and hide() (or close(), its name on sb-modal) never write the attribute, so the element's open property stays the server's word; isOpen tells whether the drawer is showing.
Styling
Style it from your page's CSS, no need to change the component or import anything into it. Custom properties, inherited properties and ::part() all reach into its shadow root.
- Size:
--sb-drawer-size(default20rem) is the width of astartorenddrawer and the height of atoporbottomone, never more than the viewport. It spans the rest of the edge. - Fonts: the heading and the body use your page's font.
- Colours: the panel is
--sb-surface-raisedwith a--sb-borderedge and footer divider, the heading--sb-text-1, the body--sb-text-2. The backdrop is--sb-surface-overlay, slightly blurred. The close button's focus ring is--sb-brand. - Stacking: a modal drawer is in the top layer. With
modal="false"it stays in the page, fixed at--sb-z-overlay(40), so an ancestor with atransformorfilterconfines it. - Parts:
panel(the dialog),heading,body,footerandclose(the close button). Your page's::part()rules win over the component's own, without!important.
<style>
.my-drawer::part(panel) { border-inline-start: 4px solid var(--sb-brand); }
.my-drawer::part(heading) { font-size: 1.25rem; }
</style>
<sb-drawer class="my-drawer" inline heading="Night shift" style="--sb-drawer-size: 16rem">Lights dimmed on decks 3 to 5.</sb-drawer>
Accessibility
The drawer is a <dialog> named by its heading (or the header slot). On open the focus moves into it: to an element with autofocus, else the first focusable one (the close button), else the panel. On close it goes back to where it was. A modal drawer sets aria-modal, makes the page inert and traps focus; a drawer with modal="false" does neither, so Tab can leave it. Escape closes it: a modal drawer wherever the focus is, a non-modal one while the focus is inside. An open layer inside it, like a menu, a popover or a nested dialog, closes first. A press that starts inside the drawer, like selecting text and releasing over the backdrop, doesn't close it. Under prefers-reduced-motion it appears and disappears without sliding.
Installation
Add Datastar with Rocket and the Starbase autoloader once per page, then use the tag: the autoloader imports each component the first time its tag appears, including tags a Datastar morph adds later.
<script type="importmap">
{ "imports": { "datastar": "https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar-rocket.js" } }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/autoloader.js"></script>
<button data-on:click="$_menu.show()">Menu</button>
<sb-drawer data-ref:_menu side="start" heading="Menu">
<a href="/">Home</a>
<a href="/fleet">Fleet</a>
</sb-drawer>No flash of undefined elements: put class="sb-cloak" on <html> and add .sb-cloak :not(:defined) { visibility: hidden } to your CSS. The autoloader removes the class once the page's components are defined.
Load just this component, pinned to this version: the minified module with its integrity hash, so the browser refuses it if a single byte changes.
<script type="importmap">
{
"imports": { "datastar": "https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar-rocket.js" },
"integrity": {
"https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar-rocket.js": "sha384-vUxZojLrF1Ar3de5h7VINqhJXBgjyZS4U49pHvGa87kum6j5Xn6JRziuARNw5ELG",
"https://starbase.zweiundeins.gmbh/c/drawer@0fa2c50a8314/drawer.min.js": "sha384-N2NRHbji02ZWE2Nr3wG7nNLUGMltM9a8Tfk078GaDJuVe/sUB+T0y0L16bVylMc3"
}
}
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/drawer@0fa2c50a8314/drawer.min.js" integrity="sha384-N2NRHbji02ZWE2Nr3wG7nNLUGMltM9a8Tfk078GaDJuVe/sUB+T0y0L16bVylMc3"></script>
<button data-on:click="$_menu.show()">Menu</button>
<sb-drawer data-ref:_menu side="start" heading="Menu">
<a href="/">Home</a>
<a href="/fleet">Fleet</a>
</sb-drawer>In production, pin today's whole catalog: its autoloader and an import map with integrity hashes, so the browser refuses any file that changed. This map covers Datastar and this component; importmap.json has the hashes of every component, to merge into yours.
<script type="importmap">
{
"imports": { "datastar": "https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar-rocket.js" },
"integrity": {
"https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar-rocket.js": "sha384-vUxZojLrF1Ar3de5h7VINqhJXBgjyZS4U49pHvGa87kum6j5Xn6JRziuARNw5ELG",
"https://starbase.zweiundeins.gmbh/c/drawer@0fa2c50a8314/drawer.min.js": "sha384-N2NRHbji02ZWE2Nr3wG7nNLUGMltM9a8Tfk078GaDJuVe/sUB+T0y0L16bVylMc3"
}
}
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/@5fa59f8dc44a/autoloader.js" integrity="sha384-uIkM/3WJfrJGqPA5t7gqz6wwMDPADCPt9hxjY8Iax8QzMQbEnlfRNoTA5xCgZcTU"></script>
<button data-on:click="$_menu.show()">Menu</button>
<sb-drawer data-ref:_menu side="start" heading="Menu">
<a href="/">Home</a>
<a href="/fleet">Fleet</a>
</sb-drawer>Components import only datastar and their own files, so copying the folder is enough: save these files, keeping each component's folder, and point the import map at your own copy of datastar-rocket.js (the /js/ paths stand for yours).
<script type="importmap">
{ "imports": { "datastar": "/js/datastar-rocket.js" } }
</script>
<script type="module" src="/js/drawer/drawer.min.js"></script>
<button data-on:click="$_menu.show()">Menu</button>
<sb-drawer data-ref:_menu side="start" heading="Menu">
<a href="/">Home</a>
<a href="/fleet">Fleet</a>
</sb-drawer>Size
Each file compressed on its own, the way it is served (gzip -9, brotli -11). The autoloader loads the minified files (esbuild), so that last column is what a page downloads; the readable source is always there too. Datastar and Rocket are shared by every component and not counted.
| File | Original | gzip | brotli | minified |
|---|---|---|---|---|
drawer.js | 8.9 kB | 3.5 kB | 3.0 kB | 2.0 kB |
API reference
Props
| Attribute | Type | Default | Description |
|---|---|---|---|
heading | string | "Drawer" | Title of the drawer (the header slot replaces it). |
side | "end" | "start" | "top" | "bottom" | "end" | The edge it slides in from. start and end follow the text direction. |
modal | boolean | true | Inert page, backdrop and trapped focus. modal="false" leaves the page usable. |
open | boolean | false | The server's word: a changed attribute wins (open="false" closes it, also after show()); a removed one is ignored. Emits no sb-open or sb-close. The property returns this word; isOpen tells whether it is showing. |
inline | boolean | false | Render in place, always visible, without an overlay or close button (previews, docs). |
closable | boolean | true | Show the close button (not on inline panels). |
Slots
| Name | Description |
|---|---|
default | Drawer body. It scrolls when it is taller than the drawer. |
header | Replaces the heading, next to the close button. It labels the drawer. |
footer | Action buttons, fixed at the bottom. Elements with data-sb-close close the drawer; sb-close reports the attribute's value (or the element's text) as detail.value. |
Events
| Name | Description |
|---|---|
sb-open | After show() opened it (not when the server opens it). |
sb-close | After the user, hide() or close() closed it (not when the server closes it). detail: { reason: button | escape | backdrop | action | api, value }. |