Drawer

A panel that slides in from an edge, for navigation, filters or an edit form.

zweiundeins <sb-drawer> drawer sheet sidebar offcanvas panel dialog overlay Since 2026-09-29 2.0 kB0Open in playground Edit on GitHub

Playground

Planets with rings, in the outer system.ResetApply

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.

Filters

Planets with rings, in the outer system.

Reset Apply

<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.

Start End Top Bottom Seven on board, two on shore leave. No alerts in this sector. Twelve crates of ice, one of spare parts. Done
<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.

Toggle tools

<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.

Ship details Starbase One Docked at bay 7. Next departure at 14:20.
<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.

Day 12. The rings of X-9 are in view.
<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.

Server opens it for 3 s The server opened this drawer and will close it. Press Escape to close it now.
<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".

  • open on the first render opens the drawer on the first paint.
  • A changed open wins: open opens the drawer, open="false" closes it, also one opened with show(). Neither sends sb-open or sb-close: the server already knows.
  • The same markup again changes nothing. A morph never reopens a drawer the user closed.
  • A removed open attribute changes nothing either. Morphs also strip attributes that were only reflected, so to close it the server sends open="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 (default 20rem) is the width of a start or end drawer and the height of a top or bottom one, 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-raised with a --sb-border edge 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 a transform or filter confines it.
  • Parts: panel (the dialog), heading, body, footer and close (the close button). Your page's ::part() rules win over the component's own, without !important.
Lights dimmed on decks 3 to 5.
<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.

FileOriginalgzipbrotliminified
drawer.js 8.9 kB3.5 kB3.0 kB2.0 kB

API reference

Props

AttributeTypeDefaultDescription
headingstring"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.
modalbooleantrueInert page, backdrop and trapped focus. modal="false" leaves the page usable.
openbooleanfalseThe 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.
inlinebooleanfalseRender in place, always visible, without an overlay or close button (previews, docs).
closablebooleantrueShow the close button (not on inline panels).

Slots

NameDescription
defaultDrawer body. It scrolls when it is taller than the drawer.
headerReplaces the heading, next to the close button. It labels the drawer.
footerAction 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

NameDescription
sb-openAfter show() opened it (not when the server opens it).
sb-closeAfter the user, hide() or close() closed it (not when the server closes it). detail: { reason: button | escape | backdrop | action | api, value }.