Date picker
A popover, a calendar and a formatted trigger. One day or two, with shortcuts.
pnpm dlx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-date-picker.jsonnpx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-date-picker.jsonbunx --bun shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-date-picker.jsonyarn dlx shadcn-svelte@latest add https://coral.imlargo.dev/r/kit-date-picker.jsonThe shadcn docs are honest about this one: there is no date picker component, there is a recipe.
Every project writes the same forty lines (a popover, a trigger button, a DateFormatter, a getLocalTimeZone(), an open flag that has to be closed by hand), and every project writes them
slightly differently.
<script lang="ts">
import DatePicker from '#lib/components/coral/kit/date-picker/date-picker.svelte';
import type { DateValue } from '#lib/components/coral/kit/date-picker/types.js';
let value = $state<DateValue>();
</script>
<div class="w-64">
<DatePicker bind:value placeholder="Pick a date" clearable />
</div>What Coral adds
- The popover closes itself, and focus goes back to the trigger. The recipe leaves that to a
hand-written
onValueChange, which is where it gets forgotten. - A range closes when it is complete, not on the first click (the single most common bug in a hand-rolled range picker).
- The trigger label is formatted, and a range is formatted as a range:
Jan 5 – 9, 2026, one month and one year, in whatever separator the locale uses. - Presets know which of them is active, which is the only reason a preset row is worth composing rather than writing inline.
- No
@internationalized/dateimport:Intlformats the day; the calendar keeps owning the dates. - Days survive timezones and DST. The recipe's
value.toDate(getLocalTimeZone())handsIntllocal midnight, and midnight is the one instant a DST jump can delete. Where that happens, the picker prints the day before the one that is selected. onchangefires when the user picks, never on mount and never whenvalueis assigned from code.
Ranges
<script lang="ts">
import DatePicker from '#lib/components/coral/kit/date-picker/date-picker.svelte';
import type { DateRange } from '#lib/components/coral/kit/date-picker/types.js';
let value = $state<DateRange>();
</script>
<div class="w-72">
<DatePicker type="range" bind:value numberOfMonths={2} placeholder="Pick a range" clearable />
</div>type="range" swaps the calendar for the range calendar and value for a { start, end }. The
popover stays open through the middle of a selection and shuts when the second end lands.
A range is half picked for as long as it takes to click the second day: value carries a start and no end, and the trigger prints that one day rather than falling back to the
placeholder. A picker that says "Pick a range" while a start day sits highlighted is lying
about its own state. It is also why the trigger snippet gets empty as a signal of its own
instead of testing value === undefined.
- No changes yet.
<script lang="ts">
import DatePicker from '#lib/components/coral/kit/date-picker/date-picker.svelte';
import type { DateRange } from '#lib/components/coral/kit/date-picker/types.js';
let value = $state<DateRange>();
let log = $state<string[]>([]);
function handleChange(next: DateRange | undefined) {
const label = next?.start && next?.end ? `${next.start} → ${next.end}` : 'no range';
log = [label, ...log].slice(0, 4);
}
</script>
<div class="flex w-72 flex-col gap-3">
<DatePicker type="range" bind:value onchange={handleChange} placeholder="Date range" clearable />
<ul class="text-sm text-muted-foreground">
{#each log as entry, index (`${index}-${entry}`)}
<li>{entry}</li>
{/each}
{#if log.length === 0}
<li>No changes yet.</li>
{/if}
</ul>
</div>onchange skips the half state. A whole range and a cleared one are both selections; half of
one is the user still mid-gesture, and a caller that fetches on change would otherwise fire a
request for a range with no end. Bind value if you do want every click.
Presets
<script lang="ts">
import { getLocalTimeZone, today } from '@internationalized/date';
import DatePicker from '#lib/components/coral/kit/date-picker/date-picker.svelte';
import type { Preset } from '#lib/components/coral/kit/date-picker/presets.js';
import type { DateRange } from '#lib/components/coral/kit/date-picker/types.js';
const zone = getLocalTimeZone();
// Thunks, not values: a tab left open overnight would otherwise still offer yesterday's "today".
const lastDays = (days: number) => () => ({
start: today(zone).subtract({ days: days - 1 }),
end: today(zone)
});
const presets: Preset<DateRange>[] = [
{ label: 'Today', value: () => ({ start: today(zone), end: today(zone) }) },
{ label: 'Last 7 days', value: lastDays(7) },
{ label: 'Last 30 days', value: lastDays(30) },
{ label: 'This month', value: () => ({ start: today(zone).set({ day: 1 }), end: today(zone) }) }
];
let value = $state<DateRange>({ start: today(zone).subtract({ days: 6 }), end: today(zone) });
</script>
<div class="w-72">
<DatePicker type="range" bind:value {presets} placeholder="Date range" />
</div>A preset is a label and the value it selects. Give it as a function whenever it is relative to now:
const presets: Preset<DateRange>[] = [
{ label: 'Today', value: () => ({ start: today(zone), end: today(zone) }) },
{ label: 'Last 7 days', value: lastDays(7) }
];A preset computed once at module scope is correct until midnight, and then quietly wrong for everyone who left the tab open. The thunk is called when the row renders and again when it is clicked, so both the highlight and the selection come from the clock as it is now.
Which brings up the part worth having: the row knows which preset is active, and the trigger
prints its label rather than the dates behind it. Last 7 days is what the user chose; making
them recognise it as Aug 24 – 31 is asking them to do arithmetic to read their own selection.
Identity cannot answer that question. A thunk hands back a fresh CalendarDate on every call, so === is always false. activePreset in presets.ts compares the day fields instead, and is exported on its own for a preset row that lives outside the
popover.
The calendar keeps everything it had
<script lang="ts">
import { getLocalTimeZone, today } from '@internationalized/date';
import DatePicker from '#lib/components/coral/kit/date-picker/date-picker.svelte';
import type { DateValue } from '#lib/components/coral/kit/date-picker/types.js';
const zone = getLocalTimeZone();
const from = today(zone);
function isWeekend(date: DateValue) {
const weekday = date.toDate(zone).getDay();
return weekday === 0 || weekday === 6;
}
let value = $state<DateValue>();
</script>
<div class="w-64">
<DatePicker
bind:value
minValue={from}
maxValue={from.add({ months: 2 })}
isDateDisabled={isWeekend}
placeholder="Business day"
/>
</div>Unrecognised props go to the calendar, not to the popover: minValue, maxValue, numberOfMonths, isDateDisabled, isDateUnavailable, captionLayout, weekStartsOn, fixedWeeks, the day snippet, all of it.
<script lang="ts">
import { getLocalTimeZone, today } from '@internationalized/date';
import { Label } from '#lib/components/ui/label/index.js';
import DatePicker from '#lib/components/coral/kit/date-picker/date-picker.svelte';
import type { DateValue } from '#lib/components/coral/kit/date-picker/types.js';
const id = $props.id();
let value = $state<DateValue>();
</script>
<div class="flex w-64 flex-col gap-3">
<Label for={id}>Date of birth</Label>
<DatePicker
{id}
bind:value
captionLayout="dropdown"
maxValue={today(getLocalTimeZone())}
format={{ dateStyle: 'long' }}
placeholder="Pick your date"
/>
</div>The popover's own surface is small on purpose, because a date picker only ever needs three knobs
from it (open, align and contentClass), and those are named props.
One rename: the calendar's placeholder (the month on screen, a DateValue) is Coral's month,
because placeholder is already the trigger's empty text everywhere else in Coral. Two props with
one name, one a string and one a date, is a coin flip every caller loses once.
Forms
<script lang="ts">
import { Button } from '#lib/components/ui/button/index.js';
import DatePicker from '#lib/components/coral/kit/date-picker/date-picker.svelte';
import type { DateRange } from '#lib/components/coral/kit/date-picker/types.js';
let value = $state<DateRange>();
let submitted = $state<string[]>([]);
function handleSubmit(event: SubmitEvent & { currentTarget: HTMLFormElement }) {
event.preventDefault();
const data = new FormData(event.currentTarget);
submitted = [...data.entries()].map(([key, entry]) => `${key} = ${entry || '—'}`);
}
</script>
<form onsubmit={handleSubmit} class="flex w-72 flex-col gap-3">
<!-- `required` holds for a half-picked range too - that is the user mid-gesture. -->
<DatePicker
type="range"
name="dateRange"
bind:value
placeholder="Date range"
clearable
required
/>
<Button type="submit" variant="outline">Submit</Button>
{#if submitted.length > 0}
<ul class="text-sm text-muted-foreground">
{#each submitted as entry (entry)}
<li>{entry}</li>
{/each}
</ul>
{/if}
</form>name puts the selection in the request as a field holding the ISO day, 2026-01-05, which is
what String(dateValue) already gives you. A range submits two: name, and endName (defaulting
to ${name}-end).
form points those fields at a form by id, for a picker that renders outside it. required blocks submission while nothing is selected. On a range it holds for a half-picked one too, which
is the user mid-gesture rather than a selection.
The pieces
<script lang="ts">
import CalendarIcon from '@lucide/svelte/icons/calendar';
import { Button } from '#lib/components/ui/button/index.js';
import DatePicker from '#lib/components/coral/kit/date-picker/date-picker.svelte';
import type { DateValue } from '#lib/components/coral/kit/date-picker/types.js';
let value = $state<DateValue>();
</script>
<DatePicker bind:value>
{#snippet trigger({ props, label, empty })}
<Button {...props} variant="ghost" size="sm">
<CalendarIcon />
{empty ? 'Schedule' : label}
</Button>
{/snippet}
{#snippet footer({ clear, close })}
<div class="flex items-center justify-between gap-2">
<Button variant="ghost" size="sm" onclick={clear}>Clear</Button>
<Button variant="ghost" size="sm" onclick={close}>Done</Button>
</div>
{/snippet}
</DatePicker>trigger replaces the button entirely and receives label already formatted, so a custom trigger
does not have to re-derive it. footer adds a strip below the calendar: a clear action, a hint, a
time field.
Formatting
format is passed straight to Intl.DateTimeFormat, so anything it understands works:
<DatePicker bind:value format={{ dateStyle: 'full' }} />
<DatePicker bind:value format={{ day: '2-digit', month: 'long' }} />Leave timeZone out of it. Coral turns the selected day into a Date at local noon and formats
that: a calendar day means that day where the reader is, and noon is the only hour no DST jump can
delete.
Import
<script lang="ts">
import DatePicker from '#lib/components/coral/kit/date-picker/date-picker.svelte';
</script>Props
Everything the wrapped calendar accepts stays available. On top of that:
| Prop | Type | Default | Description |
|---|---|---|---|
type | 'single' | 'range' | 'single' | Decides the shape of value, presets and onchange. |
value | DateValue | DateRange | - | The selection. Bindable. |
onchange | (value) => void | - | Fired when the user picks a day, a preset, or clears. |
open | boolean | false | Popover state. Bindable. |
onOpenChange | (open: boolean) => void | - | The reader opened or closed it. Not when open is set. |
month | DateValue | today | The month on screen. Bindable. Follows the selection. |
presets | Preset<Value>[] | - | Shortcuts beside the calendar. |
placeholder | string | Select a date... | Trigger text while nothing is selected. |
locale | string | en-US | Drives the calendar's strings and the trigger label. |
format | Intl.DateTimeFormatOptions | { dateStyle: 'medium' } | How the trigger prints the selection. |
clearable | boolean | false | Adds a clear control to the trigger. |
clearLabel | string | Clear date | Accessible label for that control. |
disabled | boolean | false | Blocks the trigger and the calendar. |
name | string | - | Submits as a field holding the ISO day. |
endName | string | ${name}-end | Field name for the end of a range. |
form | string | - | id of the form, for a picker outside it. |
required | boolean | false | Blocks submission while nothing is selected. |
serialize | (value: DateValue) => string | String | Turns a day into the submitted string. |
id | string | generated | Put on the trigger, for a <Label for>. |
aria-* | aria-label, -labelledby, -describedby, -invalid | - | Put on the trigger. |
align | 'start' | 'center' | 'end' | 'start' | Which trigger edge the popover lines up with. |
class | string | - | Merged onto the trigger button. |
contentClass | string | - | Merged onto the popover content. |
Snippets
| Snippet | Receives | Replaces |
|---|---|---|
trigger | { props, value, label, empty, open, disabled, clear } | The whole trigger. |
footer | { value, clear, close } | Nothing - it is added below the calendar. |
Preset<Value>
| Field | Type | Description |
|---|---|---|
label | string | Shown on the shortcut, and on the trigger while it is active. |
value | Value | () => Value | The selection it stands for. A function for anything relative. |
Deliberately absent
- No
type="multiple": the shadcn calendar has it, and surfacing it here would add a third shape (an array) tovalue, topresetsand toonchange, for a selection the trigger cannot print: a scattered set of days does not fold into one line of text the way a day or a range does. - No time: a date and a time are two controls; the shadcn recipe itself puts an
<Input type="time">next to the picker rather than inside it. Coral'sfooteris where that goes. - No natural-language parsing: it needs
chrono-node, it is English-only out of the box, and it is a text input that happens to sit beside a calendar (a different component, if it is ever written twice). - No
formatstring:Intl.DateTimeFormatOptionsis the vocabulary the platform already has, and a bespoke'dd/MM/yyyy'mini-language is a formatter Coral would then have to own.