Skip to main content
Urbicon UI

Planner

Date-indexed planning grid whose cells hold your own domain content via a generic cell snippet — week, month or custom range.

Playground

Week 25 – June 2026
Week 25 – June 2026
Today
Mon 15Tue 16Wed 17Thu 18Fri 19Sat 20Sun 21
Mon 15
Overnight Oats
Caprese Salad
Lentil Curry
Tue 16
Avocado Toast
Margherita Pizza
Wed 17
Ramen Bowl
Thu 18
Berry Pancakes
Falafel Wrap
Risotto ai Funghi
Fri 19
Tacos al Pastor
Sat 20
Poke Bowl
Sun 21
Shakshuka
View
Variant
Size
<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

The headline use case. Items bucket onto days by 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.
Week 25 – June 2026
Week 25 – June 2026
Today
Mon 15Tue 16Wed 17Thu 18Fri 19Sat 20Sun 21
Mon 15
Overnight Oats
Lentil Curry
Tue 16
Ramen Bowl
Wed 17
Avocado Toast
Margherita Pizza
Thu 18
Fri 19
Tacos al Pastor
Sat 20
Sun 21
Shakshuka
<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

The same component in month view with a different domain type. Cells stay compact; weekend columns are tinted via highlightWeekend.
June 2026
June 2026
Today
Mon Tue Wed Thu Fri Sat Sun
1
2
3
4
5
6
7
8
Early · Mara Night · Jon
9
10
Late · Ada
11
Early · Leo
12
Night · Mara
13
14
15
Early · Ada Late · Jon
16
17
18
Late · Leo
19
20
21
22
Night · Ada
23
24
25
Early · Mara
26
27
28
29
30
1
2
3
4
5
<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

Restyle any slot (here the header and cells) via slotClasses, and track the active day with bind:selectedDateisSelected reaches the cell snippet. Clicking a cell's body selects its day; clicks on interactive content keep their own behaviour.
Week 25 – June 2026
Week 25 – June 2026
Today
Mon 15Tue 16Wed 17Thu 18Fri 19Sat 20Sun 21
Mon 15
09:0014:00
Tue 16
11:00
Wed 17
Thu 18
10:0016:00
Fri 19
13:00
Sat 20
Sun 21
<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

Planner and a SvelteKit load function agree on the same week because both use the Svelte-free @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

36 props 1 required
Prop
Type
Default
Description

04 Types

Local type definitions used by this component.

8 types
Name
Kind
Category
Used by
Description

Installation

Import

import { Planner } from '@urbicon-ui/blocks';