Playground
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.
<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.
<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.
<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
28remwide (less on small screens); setinline-sizeon::part(panel)for another width. - Fonts: the heading and the body use your page's font.
- Colours: the panel is
--sb-surface-raisedwith a--sb-borderedge 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-overlaysets the dialog's drop shadow: one shadow without spread, such as0 8px 16px rgb(0 0 0 / 0.3), ornone. - 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-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.jsone 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.
| File | Original | gzip | brotli |
|---|---|---|---|
modal.bundle.min.js | 3.7 kB | 1.8 kB | 1.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.
| File | Original | gzip | brotli | minified |
|---|---|---|---|---|
modal.js | 7.1 kB | 3.0 kB | 2.6 kB | 1.5 kB |
API reference
Props
| Attribute | Type | Default | Description |
|---|---|---|---|
heading | string | "Dialog" | Title of the dialog. |
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 | Dialog body. |
footer | Action 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
| Name | Description |
|---|---|
sb-open | After show() opened it (not when the server opens it). |
sb-close | After the user or close() closed it (not when the server closes it). detail: { reason: button | escape | backdrop | action | api, value }. |