Planner
Date-indexed planning grid whose cells hold your own domain content via a generic cell snippet — week, month or custom range.
Playground
<script lang="ts">
import { Planner } from '@urbicon-ui/blocks';
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: '🍳' }
];
</script>
<Planner
{items}
>
{#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. Because cell runs for empty days too, the “Add” button is available everywhere.<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 — isSelected reaches the cell snippet. 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 — no UTC drift between server and client.// +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. Navigation pulls the focus back into view by paging when it crosses the visible window.
Interactive cell content keeps its behaviour
Interactive content inside a cell (buttons, links, inputs) keeps its own Enter/Space
and click behaviour — grid navigation only 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.
Until 2026-07-31 this defaulted to the literal 'de-DE', so an
English app rendered German month names unless every date component was
passed locale by hand. | |
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: PlannerRange) => 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 | boolean | — | Renamed to showWeekNumbers for parity with Calendar, which has carried
the plural since long before Planner existed. Still honoured, and warns in
DEV; drop it before 1.0. | |
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. | |
value | Date | today | Reference date the view is anchored on. Supports bind:value. | |
variant | defaultborderedghost | 'default' | Visual style variant for the Planner component | |
view | PlannerView | 'week' | View property for the Planner component | |
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 | — | |
PlannerSlots | type | variant | 0 | Slot names derived from the tv() config — single source of truth for slotClasses. | |
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. | |
PlannerRange | interface | helper | 0 | An inclusive start/end date pair — the visible window of the current view. | |
PlannerView | type | helper | 1 | Cell-based views Planner lays out. (day/agenda/year stay Calendar's.) |
Installation
Import
import { Planner } from '@urbicon-ui/blocks';