Plannerbeta
A date grid for planning: give it items, a getDate to bucket them onto days, and a cell snippet that renders each day. Use it for a meal plan, a shift roster, or booking slots.
Playground
<script lang="ts">
import { Planner } from '@urbicon-ui/blocks';
const value = new Date(2026, 5, 15);
const items = [
{ id: '1', date: '2026-06-15', type: 'breakfast', title: 'Overnight Oats', emoji: '🥣' },
{ id: '2', date: '2026-06-15', type: 'lunch', title: 'Caprese Salad', emoji: '🥗' },
{ id: '3', date: '2026-06-15', type: 'dinner', title: 'Lentil Curry', emoji: '🍛' },
{ id: '4', date: '2026-06-16', type: 'breakfast', title: 'Avocado Toast', emoji: '🥑' },
{ id: '5', date: '2026-06-16', type: 'dinner', title: 'Margherita Pizza', emoji: '🍕' },
{ id: '6', date: '2026-06-17', type: 'lunch', title: 'Ramen Bowl', emoji: '🍜' },
{ id: '7', date: '2026-06-18', type: 'breakfast', title: 'Berry Pancakes', emoji: '🥞' },
{ id: '8', date: '2026-06-18', type: 'lunch', title: 'Falafel Wrap', emoji: '🌯' },
{ id: '9', date: '2026-06-18', type: 'dinner', title: 'Risotto ai Funghi', emoji: '🍚' },
{ id: '10', date: '2026-06-19', type: 'dinner', title: 'Tacos al Pastor', emoji: '🌮' },
{ id: '11', date: '2026-06-20', type: 'lunch', title: 'Poke Bowl', emoji: '🐟' },
{ id: '12', date: '2026-06-21', type: 'breakfast', title: 'Shakshuka', emoji: '🍳' }
];
const MEAL_ORDER = { breakfast: 0, lunch: 1, dinner: 2 };
const getDate = (meal) => meal.date;
const sort = (a, b) => MEAL_ORDER[a.type] - MEAL_ORDER[b.type];
</script>
<Planner
{value}
{items}
{getDate}
{sort}
>
{#snippet cell({ items })}
{#each items as meal (meal.id)}
<div class="bg-surface-subtle flex items-center gap-2 rounded-md px-2 py-1.5 text-sm">
<span aria-hidden="true">{meal.emoji}</span>
<span class="text-text-secondary truncate">{meal.title}</span>
</div>
{/each}
{/snippet}
</Planner>01 Examples
Weekly meal plan
getDate, sort orders them within a cell, and the cell snippet renders your own markup. cell runs for empty days too, so the “Add” button appears on every day.<script lang="ts">
import { Planner, Button, PlusIcon } from '@urbicon-ui/blocks';
type MealType = 'breakfast' | 'lunch' | 'dinner';
interface Meal {
id: string;
date: string;
type: MealType;
title: string;
emoji: string;
}
const MEAL_ORDER: Record<MealType, number> = { breakfast: 0, lunch: 1, dinner: 2 };
let meals = $state<Meal[]>([
{ id: '1', date: '2026-06-15', type: 'breakfast', title: 'Overnight Oats', emoji: '🥣' },
{ id: '2', date: '2026-06-15', type: 'dinner', title: 'Lentil Curry', emoji: '🍛' },
{ id: '3', date: '2026-06-16', type: 'lunch', title: 'Ramen Bowl', emoji: '🍜' },
{ id: '4', date: '2026-06-17', type: 'breakfast', title: 'Avocado Toast', emoji: '🥑' },
{ id: '5', date: '2026-06-17', type: 'dinner', title: 'Margherita Pizza', emoji: '🍕' },
{ id: '6', date: '2026-06-19', type: 'dinner', title: 'Tacos al Pastor', emoji: '🌮' },
{ id: '7', date: '2026-06-21', type: 'breakfast', title: 'Shakshuka', emoji: '🍳' }
]);
let nextId = $state(8);
function addMeal(isoDate: string) {
meals.push({
id: String(nextId++),
date: isoDate,
type: 'lunch',
title: 'New meal',
emoji: '🍽️'
});
}
</script>
<Planner
view="week"
items={meals}
getDate={(m) => m.date}
sort={(a, b) => MEAL_ORDER[a.type] - MEAL_ORDER[b.type]}
value={new Date(2026, 5, 15)}
locale="en-US"
>
{#snippet cell({ items, isoDate })}
{#each items as meal (meal.id)}
<div class="bg-surface-subtle flex items-center gap-2 rounded-md px-2 py-1.5">
<span aria-hidden="true">{meal.emoji}</span>
<span class="text-text-secondary truncate text-sm">{meal.title}</span>
</div>
{/each}
<!-- Rendered on every day, including empty ones — the cell snippet drives all content. -->
<Button
variant="ghost"
size="sm"
class="mt-auto justify-start"
onclick={() => addMeal(isoDate)}
>
<PlusIcon size={14} />
Add
</Button>
{/snippet}
</Planner>
Monthly shift plan
<script lang="ts">
import { Planner, Badge } from '@urbicon-ui/blocks';
type Shift = 'early' | 'late' | 'night';
interface Assignment {
id: string;
date: string;
shift: Shift;
person: string;
}
const SHIFT_META: Record<Shift, { label: string; intent: 'success' | 'warning' | 'primary' }> = {
early: { label: 'Early', intent: 'success' },
late: { label: 'Late', intent: 'warning' },
night: { label: 'Night', intent: 'primary' }
};
const SHIFT_ORDER: Record<Shift, number> = { early: 0, late: 1, night: 2 };
// A fortnight of shift assignments across June 2026.
const assignments: Assignment[] = [
{ id: 'a', date: '2026-06-08', shift: 'early', person: 'Mara' },
{ id: 'b', date: '2026-06-08', shift: 'night', person: 'Jon' },
{ id: 'c', date: '2026-06-10', shift: 'late', person: 'Ada' },
{ id: 'd', date: '2026-06-11', shift: 'early', person: 'Leo' },
{ id: 'e', date: '2026-06-12', shift: 'night', person: 'Mara' },
{ id: 'f', date: '2026-06-15', shift: 'early', person: 'Ada' },
{ id: 'g', date: '2026-06-15', shift: 'late', person: 'Jon' },
{ id: 'h', date: '2026-06-18', shift: 'late', person: 'Leo' },
{ id: 'i', date: '2026-06-22', shift: 'night', person: 'Ada' },
{ id: 'j', date: '2026-06-25', shift: 'early', person: 'Mara' }
];
</script>
<Planner
view="month"
items={assignments}
getDate={(a) => a.date}
sort={(a, b) => SHIFT_ORDER[a.shift] - SHIFT_ORDER[b.shift]}
value={new Date(2026, 5, 1)}
locale="en-US"
highlightWeekend
>
{#snippet cell({ items })}
<div class="flex flex-wrap gap-1">
{#each items as a (a.id)}
<Badge intent={SHIFT_META[a.shift].intent} size="sm">
{SHIFT_META[a.shift].label} · {a.person}
</Badge>
{/each}
</div>
{/snippet}
</Planner>
02 Customization
slotClasses + selected day
slotClasses, and track the active day with bind:selectedDate; the cell snippet receives isSelected. Clicking a cell's body selects its day; clicks on interactive content keep their own behaviour.<script lang="ts">
import { Planner } from '@urbicon-ui/blocks';
interface Slot {
id: string;
date: string;
time: string;
booked: boolean;
}
const slots: Slot[] = [
{ id: '1', date: '2026-06-15', time: '09:00', booked: true },
{ id: '2', date: '2026-06-15', time: '14:00', booked: false },
{ id: '3', date: '2026-06-16', time: '11:00', booked: false },
{ id: '4', date: '2026-06-18', time: '10:00', booked: true },
{ id: '5', date: '2026-06-18', time: '16:00', booked: false },
{ id: '6', date: '2026-06-19', time: '13:00', booked: false }
];
let selectedDate = $state<Date | undefined>(new Date(2026, 5, 16));
</script>
<Planner
view="week"
items={slots}
getDate={(s) => s.date}
sort={(a, b) => a.time.localeCompare(b.time)}
value={new Date(2026, 5, 15)}
bind:selectedDate
locale="en-US"
slotClasses={{
header: 'rounded-t-xl bg-surface-inverted px-3',
headerTitle: 'text-text-inverted',
navButton: 'text-text-inverted/70 hover:text-text-inverted hover:bg-white/10',
cell: 'rounded-xl border-2 transition-all'
}}
>
{#snippet cell({ items, isSelected })}
<div class={['flex flex-col gap-1', isSelected && 'font-medium']}>
{#each items as slot (slot.id)}
<span
class={[
'rounded-md px-2 py-1 text-sm tabular-nums',
slot.booked
? 'bg-danger-subtle text-danger line-through'
: 'bg-success-subtle text-success'
]}
>
{slot.time}
</span>
{/each}
</div>
{/snippet}
</Planner>
Server-safe weeks
@urbicon-ui/blocks/date subpath, so the server and client resolve the same days.// +page.server.ts
import { startOfWeek, endOfWeek, toIso } from '@urbicon-ui/blocks/date';
export async function load({ url }) {
const ref = url.searchParams.get('w') ? new Date(url.searchParams.get('w')!) : new Date();
const start = startOfWeek(ref, 1); // Monday
const meals = await db.meals.between(toIso(start), toIso(endOfWeek(ref, 1)));
return { meals, start: toIso(start) };
}03 Accessibility
The ARIA grid pattern
The grid uses the ARIA grid pattern: role="grid" wraps row/columnheader/gridcell, the active day carries aria-selected, and a roving tabindex keeps a single tab stop.
Keyboard
Keyboard: arrow keys move the focused day, Home/End jump to the week edges, PageUp/PageDown step a month (Shift a year), and Enter/Space select. When the focused day crosses the visible window, the grid pages to keep it in view.
Interactive cell content keeps its behaviour
Interactive content inside a cell (buttons, links, inputs) keeps its own Enter/Space
and click behaviour. Grid navigation fires from the cell itself, and only a click on the cell
body selects the day.
Navigation is announced
The localized view title is mirrored into an aria-live="polite" status region,
so screen readers announce navigation. Focus rings use focus-visible only.
Reduced motion
Transitions and swipe respect prefers-reduced-motion (set animated=false to opt out entirely).
API Reference
Prop | Type | Default | Description | |
|---|---|---|---|---|
getDate required | (item: T) => Date | string | — | Map an item to its calendar day. Return a Date, or a local date string
('2026-06-16') taken verbatim — never UTC-parsed, so a plain date never
shifts across timezones. A date-*time* string is bucketed by its written
date part too; if your value is a UTC instant whose local day matters
('…T23:00:00Z'), return new Date(value) so the local timezone applies.
Required. | |
animated | boolean | true | Slide-transition the grid on navigate (respects reduced-motion). | |
cell | Snippet<[PlannerCellContext<T>]> | — | Render a day's content — the core of the API. Receives bucketed items: T[].
Called for **every** day, including empty ones (items: []) — unless an
empty snippet is given, which then handles empty days instead. Put an
"add" affordance here to keep it available on empty days. | |
class | string | — | Extra classes merged onto the root element. | |
dayHeader | Snippet<[PlannerDayContext]> | — | Customise each weekday/column header. | |
disabled | boolean | false | Disable navigation and selection. | |
disabledDates | Date[] | — | Specific dates that cannot be selected. | |
empty | Snippet<[PlannerCellContext<T>]> | — | Placeholder rendered **instead of** cell for days with no items. Omit it
to let cell render empty days too. | |
fixedWeeks | boolean | false | Always render 6 week rows in view="month", so the grid keeps its height
across months of 4, 5 and 6 rows. Ignored in week/range. | |
header | Snippet<[PlannerHeaderContext]> | — | Replace the default toolbar (prev/next/today/title/week). | |
highlightToday | boolean | true | Visually mark today's cell. | |
highlightWeekend | boolean | false | Tint Saturday/Sunday cells. | |
isDateDisabled | (date: Date) => boolean | — | Predicate for dates that cannot be selected, on top of minDate/maxDate. | |
items | T[] | — | The items to lay out. Each is bucketed onto a day via getDate. | |
locale | string | 'auto' | BCP 47 locale tag for date formatting — month names, weekday names and the
header title. Defaults to 'auto', which follows the active
<I18nProvider> locale, so an app that already declares its language does
not have to repeat it here. SSR-safe: the locale comes from context, so the
server and the client resolve the same tag (Intl with undefined would
follow the runtime and disagree across hydration). Falls back to the base
locale (en) when no provider is mounted. Pass an explicit tag
(e.g. 'de-DE', 'ja-JP') to override. | |
maxDate | Date | — | Latest navigable/selectable date. | |
minDate | Date | — | Earliest navigable/selectable date. | |
onDateSelect | (date: Date) => void | — | Fires when a day cell is activated (click / Enter / Space). | |
onNavigate | (date: Date, range: DateRange) => void | — | Fires after navigation. Receives the new reference date and visible range — load data here. | |
preset | string | — | Apply a named preset registered via <BlocksProvider presets={{ Planner: {...} }}>. | |
rangeEnd | Date | — | End of the window for view="range". | |
rangeStart | Date | — | Start of the window for view="range". | |
selectedDate | Date | — | The active highlighted day. Supports bind:selectedDate. | |
showWeekNumber deprecated | boolean | — | Renamed to showWeekNumbers for parity with Calendar, which has carried
the plural since long before Planner existed. Still honoured, and warns in DEV. | |
showWeekNumbers | boolean | false | Show the ISO week-number column on the left. | |
size | smmdlg | 'md' | Density of the grid and header. | |
slotClasses | Partial<Record<PlannerSlots, string>> | — | Per-slot class overrides merged with tv() styles. Slots: base | header | headerTitle | nav | navButton | grid | weekdayHeader | weekday | weekNumber | week | cell | cellHeader | cellWeekday | cellDate | cellItems | empty | |
sort | (a: T, b: T) => number | — | Comparator for items within a day cell (e.g. by meal type or start label). | |
swipeable | boolean | true | Enable horizontal swipe-to-navigate on touch. | |
unstyled | boolean | false | Remove all default tv() classes — only user-provided classes apply. Reaches the header's today-button tooltip. | |
value | Date | today | Reference date the view is anchored on. Supports bind:value. | |
variant | defaultborderedghost | 'default' | Visual treatment. | |
view | PlannerView | 'week' | Layout mode. | |
weekStartsOn | 0123 +3 more | 1 | First day of the week (0=Sun … 6=Sat). | |
...HTMLAttributes<HTMLDivElement> inherited | HTMLAttributes | — | HTML attributes (excluding: 'children') | |
...PlannerVariants variant | VariantProps | — | Styling variants from PlannerVariants |
04 Types
Local type definitions used by this component.
Name | Kind | Category | Used by | Description | |
|---|---|---|---|---|---|
PlannerProps | interface | props | 0 | — | |
PlannerVariants | type | variant | 0 | — | |
PlannerCellState | type | helper | 0 | The axes a single day cell varies on, resolved at the slot call rather than
from a prop. PlannerProps subtracts keyof PlannerCellState from what it
inherits off this config, which is what keeps these axes out of the
catalogue's variant list.
The subtraction reaches the inherited surface and nothing else. PlannerProps
declares view, variant and size in its own body, so naming one of those
here strips its variant metadata while the prop stays writable — measured with
'size' added: the catalogue drops to variants: ['variant'], losing the
options and the playground knob, and <Planner size="lg"> still type-checks.
Adding a key here is therefore half a move; the other half is that no member
of the interface body declares it. | |
PlannerSlots | type | variant | 0 | Slot names derived from the tv() config — single source of truth for slotClasses. | |
DateRange | interface | helper | 0 | An inclusive start/end date pair: a selected range, a visible window, the
range onNavigate reports. One type for Calendar, Planner and
ResourceTimeline alike.
ResourceTimeline.getRange is not this type: it also accepts local date
strings ('2026-06-16'), which a selection value must not. | |
PlannerCellContext | interface | helper | 0 | The value the cell snippet receives per day — the heart of the API. items
carries the bucketed, sorted domain objects with their real T type (no
CalendarEvent cast). | |
PlannerDayContext | interface | helper | 0 | Per-column / per-day context for the weekday header snippet. | |
PlannerHeaderContext | interface | helper | 0 | Context for the header snippet — everything to build a custom toolbar. | |
PlannerView | type | helper | 1 | Cell-based views Planner lays out. (day/agenda/year stay Calendar's.) |
Installation
Import
import { Planner } from '@urbicon-ui/blocks';