Playground
A text field for a date, with a button that opens a calendar. The field shows the date the way the page's language writes it and takes typed dates too. With inline, the calendar sits on the page on its own, always visible.
mode="range": pick a start and an end. The range is one value ({start, end}), committed once, likesb-range.min,max,disabled-dates: days that can't be picked, e.g. booked ones. The server sends them, also one month at a time.monthandopen: view state the server may set. The calendar reports the user paging months withsb-month.
The value is an ISO date (2026-09-29) in and out, whatever the language. The live value is the value property, so data-bind works.
Examples
A date
Type a date, or open the calendar with the button:
<div data-signals="{_launch: '2026-10-14'}" style="display: grid; gap: 12px">
<sb-date-picker label="Launch date" value="2026-10-14" data-bind:_launch__prop.value></sb-date-picker>
<span>Value: <code data-text="$_launch || 'none'"></code></span>
</div>
A range
The first pick marks the start, the second the end, and only then does the value change, with one sb-change. Picking the end first works too: the two are put in order.
<div data-signals="{_stay: ''}" style="display: grid; gap: 12px">
<sb-date-picker mode="range" name="stay" label="Stay" value='{"start":"2026-10-06","end":"2026-10-09"}'
data-on:sb-change="$_stay = JSON.stringify(evt.detail)"></sb-date-picker>
<code data-text="$_stay || 'Pick two dates…'"></code>
</div>
el.value returns { start, end } (or null when there is no range), and setting it takes { start, end }, [start, end] or the JSON.
Inline, with bounds and ruled-out days
min and max limit the calendar, and disabled-dates rules out single days. They can be focused and read, but not picked.
<sb-date-picker inline label="Launch window" value="2026-10-14" month="2026-10"
min="2026-10-05" max="2026-11-20"
disabled-dates='["2026-10-10","2026-10-11","2026-10-17","2026-10-18"]'></sb-date-picker>
Booked days, one month at a time
When the user moves to another month, the picker emits sb-month with { name, year, month } (month from 1). The page asks the server for that month's booked days, and the server answers with disabled-dates: a signal patch handed over with data-attr, or a morph. Here the page makes the days up itself:
<div data-signals="{_booked: ['2026-10-03','2026-10-04','2026-10-12','2026-10-13','2026-10-24']}">
<sb-date-picker inline mode="range" label="Your nights" month="2026-10"
data-attr:disabled-dates="JSON.stringify($_booked)" data-preserve-attr="disabled-dates"
data-on:sb-month="$_booked = [3, 4, 12, 13, 24].map((d) => [evt.detail.year, evt.detail.month, d].map((n) => String(n).padStart(2, '0')).join('-'))"></sb-date-picker>
</div>
On a real page, sb-month asks the server:
<sb-date-picker mode="range" data-attr:disabled-dates="JSON.stringify($_booked)" data-preserve-attr="disabled-dates"
data-on:sb-month="@get('/booked?year=' + evt.detail.year + '&month=' + evt.detail.month)"></sb-date-picker>
func booked(w http.ResponseWriter, r *http.Request) {
y, _ := strconv.Atoi(r.URL.Query().Get("year"))
m, _ := strconv.Atoi(r.URL.Query().Get("month"))
datastar.NewSSE(w, r).MarshalAndPatchSignals(map[string]any{
"_booked": bookedDays(y, time.Month(m)), // ["2026-10-03", ...]
})
}
The first month comes with the page, so it needs no request. disabled-dates is server data: it only flows in, and a new list never touches the value or a range the user is halfway through.
Languages
Month and weekday names come from the browser's Intl, in the element's lang (or the nearest one above it, also outside another component's shadow root). The language also picks the first day of the week (Monday where the browser doesn't know) and the format the field shows and reads. A tag with an underscore (de_DE) works; one the browser can't read falls back to its own language.
<div style="display: grid; gap: 12px">
<sb-date-picker lang="de" label="Startdatum" value="2026-10-14"></sb-date-picker>
<sb-date-picker lang="en-US" label="Start date" value="2026-10-14"></sb-date-picker>
<sb-date-picker lang="ja" label="開始日" value="2026-10-14"></sb-date-picker>
</div>
Typing
The field takes a date in the language's numeric format (14.10.2026 in German, 10/14/2026 in US English, 2026/10/14 in Japanese, in the language's own digits) or in ISO (2026-10-14), with a four-digit year. The placeholder shows the pattern, e.g. dd.mm.yyyy. With mode="range", type both dates with a dash between them (14.10.2026 - 18.10.2026).
The date is read when the field is committed (Enter, or leaving it). Text that isn't a date, or is a date that can't be picked, stays in the field, marked invalid, and nothing is sent: the picker never guesses. error sets the message shown below the field. An empty field clears the value.
<sb-date-picker label="Return date" min="2026-10-01" error="Enter a date from 1 October 2026, like 14.10.2026." lang="de"></sb-date-picker>
With commands
Give it a name, and it emits sb-change with { name, value } when the value changes: ready to post as a command. With confirm, it sets :state(pending) until the server's re-rendered value matches, and revert() goes back to the server's value when a command is rejected. See Commands and components.
<sb-date-picker name="launch" confirm value="2026-10-14"
data-on:sb-change="@post('/cmd/launch', {payload: {tabid: $tabid, ...evt.detail}})"
data-on:datastar-fetch="evt.detail.el === el && evt.detail.type === 'error' && el.revert()"></sb-date-picker>
A new value from the server always wins, and value="" clears it. Markup re-sent with the same value leaves the user's pick alone. A range is one command: the server accepts or rejects both ends together, and validates what lies between them (a booked night inside the range, say).
open and month are view state, not part of the value: never pending, and not touched by revert(). The server may set them, and a changed attribute wins (open="false" closes). The picker reports the user's changes with sb-toggle ({ name, open }) and sb-month, never for a change the server made.
Forms
Inside a <form>, sb-date-picker submits its value under its name: the ISO date (launch=2026-10-14), with mode="range" the JSON of its value attribute, and an empty string when there is no date, like <input type="date">. A disabled picker submits nothing. new FormData(form) and Datastar's contentType: 'form' include it, and a form reset brings back the server's value and clears invalid typed text. It is not a form-associated element yet (Rocket can't declare one), so required and validity, <fieldset disabled>, <label for> and the form attribute don't reach it. With commands, sb-change carries { name, value } (see With commands).
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 field fills the width it is given, up to
20rem; setmax-inline-sizeon the element to change that. The field is2.75remtall, and each day2.25remsquare, so the calendar is about17remwide. - Fonts: the label, the field, the month and the days use your page's font.
- Colours: the field is
--sb-control-bgwith a--sb-control-borderedge (--sb-control-border-hoveron hover) and--sb-control-text; the placeholder is--sb-control-placeholder, the label--sb-text-2, the button and the weekdays--sb-text-muted. Focus draws a--sb-brand-lightedge with a--sb-brand-subtleglow, and invalid text a--sb-dangeredge and message. The calendar is--sb-surface-raised; a picked day is--sb-brandwith--sb-text-on-brandtext, the days of a range--sb-brand-subtle, the day under the pointer--sb-surface-hover, and today--sb-brand-light. Corners are--sb-control-radius, and--sb-notch: 0rounds the calendar and the days instead of notching them. - Parts:
label,control(the field's box),input,button(opens the calendar),error,calendar,title(the month),nav(the four paging buttons) andgrid. Every date isday, plustoday,selected,range,start,endordisabledas they apply:::part(day today). Your page's::part()rules win over the component's own, without!important.
<style>
.my-dates { --sb-brand: #F97316; --sb-brand-light: #FDBA74; --sb-brand-subtle: rgb(249 115 22 / 0.18); --sb-notch: 0; }
.my-dates::part(day disabled) { text-decoration: none; opacity: 0.3; }
</style>
<sb-date-picker class="my-dates" inline mode="range" month="2026-10" value='{"start":"2026-10-12","end":"2026-10-16"}' disabled-dates='["2026-10-20","2026-10-21"]'></sb-date-picker>
Accessibility
It follows the ARIA date picker dialog pattern:
- Structure: the field is a text input with its label. The button (
aria-haspopup="dialog",aria-expanded) opens the calendar, a dialog named by the label (or "Choose date"). The days are agridnamed by the month, whose name is announced when it changes. Each day is named by its full date; the picked ones arearia-selected, today isaria-current="date", and a day that can't be picked isaria-disabled. - Keys in the calendar:
- Left and Right move a day (mirrored right to left), Up and Down a week.
- Page Up and Page Down move a month, with Shift a year.
- Home and End go to the first and last day of the week.
- Enter or Space picks the day; a day that can't be picked is skipped by picking, but can still be focused and read.
- Escape closes the calendar, also from the field, and puts the focus back on the button (inline, it drops a half-picked range). In a drawer, a modal or a popover, the first Escape closes only the calendar. Tab moves between the paging buttons and the grid, and stays in the calendar while it is open.
- Opening: the focus goes to the picked day, else to today. Picking a date closes the calendar and returns the focus to the button.
- Invalid text: the field is
aria-invalid, and theerrormessage is announced (a live region). - Disabled:
disabledtakes the field, the button and the calendar out of the tab order. - Forced colours: focus rings, the arrows and today's mark use system colours, and picked days
Highlight.
The calendar is a native popover (popover="auto"), so it is never clipped by a scrolling container, and a click outside it closes 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.
<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>
<sb-date-picker label="Launch date" value="2026-10-14"></sb-date-picker>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/date-picker@897f219164a0/date-picker.min.js": "sha384-e5aRA5HTmx3kVUunZ0Mq9x36wJYJRbE5CkWA3pEHCJ7rCJ6ZTV7ceZYlLnmX2NlR"
}
}
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/date-picker@897f219164a0/date-picker.min.js" integrity="sha384-e5aRA5HTmx3kVUunZ0Mq9x36wJYJRbE5CkWA3pEHCJ7rCJ6ZTV7ceZYlLnmX2NlR"></script>
<sb-date-picker label="Launch date" value="2026-10-14"></sb-date-picker>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/date-picker@897f219164a0/date-picker.min.js": "sha384-e5aRA5HTmx3kVUunZ0Mq9x36wJYJRbE5CkWA3pEHCJ7rCJ6ZTV7ceZYlLnmX2NlR"
}
}
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/@5fa59f8dc44a/autoloader.js" integrity="sha384-uIkM/3WJfrJGqPA5t7gqz6wwMDPADCPt9hxjY8Iax8QzMQbEnlfRNoTA5xCgZcTU"></script>
<sb-date-picker label="Launch date" value="2026-10-14"></sb-date-picker>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/date-picker/date-picker.min.js"></script>
<sb-date-picker label="Launch date" value="2026-10-14"></sb-date-picker>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 |
|---|---|---|---|---|
date-picker.js | 27.4 kB | 10.0 kB | 8.8 kB | 5.8 kB |
API reference
Props
| Attribute | Type | Default | Description |
|---|---|---|---|
value | string | "" | The date, ISO: 2026-09-29. With mode="range", JSON: {"start":"2026-09-29","end":"2026-10-03"}. A new value from the server replaces it (value="" clears); the live value is the value property. |
mode | "single" | "range" | "single" | One date, or a start and an end, committed together as one value. |
min | string | "" | Earliest date that can be picked (ISO). |
max | string | "" | Latest date that can be picked (ISO). |
disabled-dates | json | [] | Server data: dates that can't be picked, as a JSON array of ISO dates (e.g. booked days). They can still be focused and read. |
month | string | "" | The month shown, YYYY-MM (view state). A changed attribute from the server moves the calendar there; the user paging months emits sb-month. |
open | boolean | false | The calendar popover is open. View state: a changed attribute from the server opens or closes it (open="false" closes); local toggling never reflects it. |
inline | boolean | false | Show the calendar on the page, always visible, without the text field. |
label | string | "" | Visible label, and the accessible name of the calendar. |
placeholder | string | "" | Placeholder text (default: the locale's date pattern, e.g. dd.mm.yyyy). |
error | string | "" | Message shown when the typed text is not a date that can be picked. |
lang | string | "" | Locale for month and weekday names, the first day of the week and the typed format (default: the page's lang, then the browser's). |
disabled | boolean | false | Disable the field and the calendar. A form leaves it out. |
confirm | boolean | false | Server-confirmed value: :state(pending) while the local value differs from the server's value attribute (see revert()). |
name | string | "" | Name reported in sb-change, sb-month and sb-toggle (e.g. the field of a command), and submitted with its form. |
Events
| Name | Description |
|---|---|
change | The value changed. |
sb-change | A date was picked or typed. detail: { name, value }: an ISO date ("" when cleared), or with mode="range" { start, end } (null when cleared). Ready for a command. |
sb-month | The user moved the calendar to another month. detail: { name, year, month } (month from 1): the moment to send that month's disabled-dates. |
sb-toggle | The popover opened or closed. detail: { name, open }. View state: not emitted for a change the server made. |