Modal

Accessible modal dialogs for any use case.

zweiundeins <sb-modal> dialog overlay popup confirm Since 2026-09-21 MIT licence 1.6 kB1 Open in playground Edit on GitHub

Playground

Are you ready to launch?CancelLaunch

A modal built on the native <dialog>. The browser traps focus, closes on Escape and handles the top layer. Open it with show() and close it with close(), or mark footer buttons with data-sb-close.

Examples

Open from Datastar

data-ref gives you the element as a signal, so any Datastar expression can call its methods.

Launch sequence Are you ready to launch? This cannot be undone. Cancel Launch

<div>
  <sb-button data-on:click="$_launch.show()">Launch sequence</sb-button>
  <sb-modal data-ref:_launch heading="Mission Control" data-on:sb-close="$_answer = evt.detail.value">
    Are you ready to launch? This cannot be undone.
    <sb-button slot="footer" variant="ghost" data-sb-close="cancel">Cancel</sb-button>
    <sb-button slot="footer" data-sb-close="launch">Launch</sb-button>
  </sb-modal>
  <p data-signals:_answer="''" data-show="$_answer" data-text="'You chose: ' + $_answer"></p>
</div>

Inline

The inline attribute renders the panel in place, which is handy for docs and previews. An inline panel is always visible and has no close button.

All systems nominal. Welcome aboard.
<sb-modal inline heading="Docking complete" closable="false">
  All systems nominal. Welcome aboard.
</sb-modal>

Opened by the server

In a CQRS app the server owns "is the dialog open?". The button below stands in for the server: it sets the open attribute, exactly like a re-render would, and three seconds later sends open="false". Close the dialog 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 dialog 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-modal heading="Incoming transmission" data-attr:open="$_srv" data-preserve-attr="open"
    data-on:sb-close="$_srv = 'false'">
    The server opened this dialog and will close it. Press Escape to close it now.
  </sb-modal>
</div>

On a Starbase page the same thing is one sb-close handler and one command:

<sb-modal heading="Edit profile" open="{{ .Editing }}"
  data-on:sb-close="@post('/cmd/edit-profile/close', {payload: {tabid: $tabid, ...evt.detail}})">
  …
</sb-modal>

The command clears the flag, and the next render sends open="false". Without it the server's open stays as it was, and to open the dialog again it first has to change its word.

The server sends What happens
open on the first render Open, as a modal, on the first paint
a changed open value It wins: open opens the dialog, open="false" closes it, also one opened with show(). No sb-open or sb-close: the server already knows
the same markup again Nothing. A morph never reopens a dialog the user dismissed
no open attribute any more Nothing. Morphs also strip attributes that were only reflected, so a removal is not an instruction: send open="false"

show() and close() never write the attribute, so the element's open property is the server's word (false after show()); isOpen tells whether the dialog is showing.

Buttons that close it

Any element in the dialog with data-sb-close closes it and reports data-sb-close'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 close(). sb-open and sb-close fire for show(), close() and the user, never for a change the server made. In a nested sb-modal or sb-drawer, data-sb-close closes only the one it belongs to.

A <form> in the body can't close the dialog 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 dialog from the form's submit handler or let the server close it.

Styling

Style it from your page's CSS, without changing the component or importing anything into it. Custom properties, inherited properties and ::part() all reach into its shadow root.

  • Size: the dialog is 28rem wide (less on small screens); set inline-size on ::part(panel) for another width.
  • Fonts: the heading and the body use your page's font.
  • Colours: the panel is --sb-surface-raised with a --sb-border edge and 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; corners are --sb-radius.
  • Shadow: --sb-shadow-overlay sets the dialog's drop shadow: one shadow without spread, such as 0 8px 16px rgb(0 0 0 / 0.3), or none.
  • 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.
The countdown stops and the crew stands down.
<style>
  .my-modal::part(panel) { inline-size: min(22rem, 100%); border-radius: 0; }
  .my-modal::part(heading) { font-size: 1.25rem; }
</style>
<sb-modal class="my-modal" inline heading="Abort launch?">The countdown stops and the crew stands down.</sb-modal>

Accessibility

The native modal <dialog> sets aria-modal, traps focus and restores it on close. The heading labels the dialog. Escape and a click on the backdrop both close it, and sb-close reports why in detail.reason. A press that starts inside the dialog, like selecting text and releasing over the backdrop, doesn't close it.

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. The Datastar here is v1.0.4 with Starbase's fixes until they are released; the components also run on the official release.

<script type="importmap">
  { "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" } }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/autoloader.js"></script>

<button data-on:click="$_confirm.show()">Delete…</button>
<sb-modal data-ref:_confirm heading="Delete this file?">
  This cannot be undone.
  <button slot="footer" data-sb-close="cancel">Cancel</button>
  <button slot="footer" data-sb-close="delete">Delete</button>
</sb-modal>

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: all its modules in one minified file with its integrity hash, so the browser refuses it if a single byte changes.

<script type="importmap">
  {
    "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
    "integrity": {
      "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5"
    }
  }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/modal@abd0d23560ed/modal.bundle.min.js" integrity="sha384-UGygrJu0y/ZkqWX6xljtENUH5CKlnwaN0oeXGK+fJZCSwKBCSyOtV7D/cLy3j6Nh"></script>

<button data-on:click="$_confirm.show()">Delete…</button>
<sb-modal data-ref:_confirm heading="Delete this file?">
  This cannot be undone.
  <button slot="footer" data-sb-close="cancel">Cancel</button>
  <button slot="footer" data-sb-close="delete">Delete</button>
</sb-modal>

Or the minified module files (one file, which the browser requests as it finds their imports), each pinned in the import map:

<script type="importmap">
  {
    "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
    "integrity": {
      "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5",
      "https://starbase.zweiundeins.gmbh/c/modal@abd0d23560ed/modal.min.js": "sha384-oaj5uMZkgIUaNRFNb0Y/2OOtG9SA+VpVVgjD3JIMt+C1U/Sd29a/SR4pktNW5fgR"
    }
  }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/modal@abd0d23560ed/modal.min.js" integrity="sha384-oaj5uMZkgIUaNRFNb0Y/2OOtG9SA+VpVVgjD3JIMt+C1U/Sd29a/SR4pktNW5fgR"></script>

<button data-on:click="$_confirm.show()">Delete…</button>
<sb-modal data-ref:_confirm heading="Delete this file?">
  This cannot be undone.
  <button slot="footer" data-sb-close="cancel">Cancel</button>
  <button slot="footer" data-sb-close="delete">Delete</button>
</sb-modal>

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://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
    "integrity": {
      "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5",
      "https://starbase.zweiundeins.gmbh/c/modal@abd0d23560ed/modal.bundle.min.js": "sha384-UGygrJu0y/ZkqWX6xljtENUH5CKlnwaN0oeXGK+fJZCSwKBCSyOtV7D/cLy3j6Nh"
    }
  }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/@339249aca48f/autoloader.js" integrity="sha384-ZNpMayI1XShztIEX0iDHyCDDbhT8H+t+zEESUq4BCrN4b+7m931LQEl5x0vbY+Vo"></script>

<button data-on:click="$_confirm.show()">Delete…</button>
<sb-modal data-ref:_confirm heading="Delete this file?">
  This cannot be undone.
  <button slot="footer" data-sb-close="cancel">Cancel</button>
  <button slot="footer" data-sb-close="delete">Delete</button>
</sb-modal>

Components import only datastar and their own files, so copying is enough: save each component's one file (and a file it loads on first use, keeping its folder), and point the import map at your own copy of datastar-rocket.js (the /js/ paths stand for yours).

  • <sb-modal>
    • modal.bundle.min.js one file 1.6 kB brotli
<script type="importmap">
  { "imports": { "datastar": "/js/datastar-rocket.js" } }
</script>
<script type="module" src="/js/modal/modal.bundle.min.js"></script>

<button data-on:click="$_confirm.show()">Delete…</button>
<sb-modal data-ref:_confirm heading="Delete this file?">
  This cannot be undone.
  <button slot="footer" data-sb-close="cancel">Cancel</button>
  <button slot="footer" data-sb-close="delete">Delete</button>
</sb-modal>

Or copy the module files, keeping each component's folder, and load each component's module:

<script type="importmap">
  { "imports": { "datastar": "/js/datastar-rocket.js" } }
</script>
<script type="module" src="/js/modal/modal.min.js"></script>

<button data-on:click="$_confirm.show()">Delete…</button>
<sb-modal data-ref:_confirm heading="Delete this file?">
  This cannot be undone.
  <button slot="footer" data-sb-close="cancel">Cancel</button>
  <button slot="footer" data-sb-close="delete">Delete</button>
</sb-modal>

Size

A page loads this component as one file, its bundle, compressed the way it is served (gzip -9, brotli -11): brotli is what it downloads. Datastar and Rocket are shared by every component and not counted.

FileOriginalgzipbrotli
modal.bundle.min.js 3.7 kB1.8 kB1.6 kB
As module files: one request, 1.5 kB minified with brotli

For a page that loads the module files as they are, requesting each one as it finds its import, or bundles them itself. Each file compressed on its own; the minified one is what the page downloads, and the readable source is always there too.

FileOriginalgzipbrotliminified
modal.js 7.1 kB3.0 kB2.6 kB1.5 kB

API reference

Props

AttributeTypeDefaultDescription
headingstring"Dialog"Title of the dialog.
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
defaultDialog body.
footerAction buttons. Elements with data-sb-close close the dialog; 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 or close() closed it (not when the server closes it). detail: { reason: button | escape | backdrop | action | api, value }.