Agenda Tracks Grid
A time × track conference agenda grid — the timeline column freezes to the left, session cards reflow into place with FLIP when tracks or speakers are filtered, and clicking a session expands the speaker and summary in place.
npx shadcn@latest add https://webberui.com/r/agenda-tracks-grid.jsonPlayground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<AgendaTracksGrid />
Installation
npx shadcn@latest add https://webberui.com/r/agenda-tracks-grid.jsonOr, once registries are configured in components.json, install it as @webberui/agenda-tracks-grid.
Usage
Tracks go in through tracks (which determines the columns) and sessions through sessions. Each intersection of a trackId and a start maps to at most one session. The toolbar's track toggles and speaker filter are managed inside the component by default, and can also be driven from outside through controlled props.
import {
AgendaTracksGrid,
type AgendaTrack,
type AgendaSession,
} from "@/components/ui/agenda-tracks-grid";
const tracks: AgendaTrack[] = [
{ id: "keynote", label: "Main stage" },
{ id: "workshop", label: "Workshop" },
];
const sessions: AgendaSession[] = [
{
id: "s1",
trackId: "keynote",
start: "09:00",
title: "Opening: animation-first interfaces",
speaker: "Angela Lin",
summary: "From first principles: how motion carries an information hierarchy.",
},
{
id: "s2",
trackId: "workshop",
start: "09:00",
title: "Hands-on: building FLIP reflow",
speaker: "Tzu-Hsuan Chou",
summary: "Using layout animation to build a grid that reflows the moment it switches.",
},
];
<AgendaTracksGrid tracks={tracks} sessions={sessions} defaultExpandedId="s1" />Props
| Prop | Type | Default | Description |
|---|---|---|---|
tracks | AgendaTrack[] | — | List of tracks, determining the columns' content and their left-to-right order |
sessions | AgendaSession[] | — | List of sessions; each track + time intersection holds at most one |
times | string[] | — | Order of the time rows; when omitted, they are collected from the sessions and sorted lexicographically |
activeTracks | string[] | — | Controlled: array of track ids currently shown |
defaultActiveTracks | string[] | all | Tracks shown initially in uncontrolled mode |
onActiveTracksChange | (ids: string[]) => void | — | Fires when the shown tracks change |
speaker | string | null | — | Controlled: speaker filter (null means all) |
defaultSpeaker | string | null | null | Initial speaker filter in uncontrolled mode |
onSpeakerChange | (speaker: string | null) => void | — | Fires when the speaker filter changes |
expandedId | string | null | — | Controlled: id of the currently expanded session |
defaultExpandedId | string | null | null | Session expanded initially in uncontrolled mode |
onExpandedChange | (id: string | null) => void | — | Fires when the expanded session changes |
showControls | boolean | true | Whether to show the built-in track / speaker filter toolbar |
maxHeight | number | 360 | Maximum height of the scroll container (px) |
timeColumnWidth | number | 68 | Width of the timeline column (px) |
trackMinWidth | number | 152 | Minimum column width per track (px); too many columns triggers horizontal scrolling |
className | string | — | Appended to the outermost container's className |
AgendaTrack
| Field | Type | Description |
|---|---|---|
id | string | Unique track id, matching AgendaSession.trackId |
label | string | Track display name (column header) |
AgendaSession
| Field | Type | Description |
|---|---|---|
id | string | Unique session id |
trackId | string | Id of the track it belongs to |
start | string | Start-time label; must match one of the time rows |
title | string | Session title |
speaker | string | Speaker name (used for filtering and shown on expand) |
summary | React.ReactNode | Summary content shown once expanded |
Animation details
- Frozen headers: the timeline column is pinned to the left with
sticky leftand the track headers to the top withsticky top, with the top-left corner intersection stacked above both; when the columns exceed the container width it scrolls horizontally and the timeline column stays visible throughout. - FLIP reflow: toggling a track removes a whole column, and filtering by speaker automatically collapses tracks with no matching sessions; exiting session cards are frozen in place by
popLayoutand fade out while the rest reflow smoothly withlayout="position", avoiding text scaling distortion. - In-place expansion: clicking a session card expands the speaker and summary right where it sits, growing that time row's height while the rows below are pushed down by the same spring; clicking it again or clicking another card collapses it (only one expands at a time).
- Speaker filter: non-matching session cards fade into a disabled state, preserving the time-aligned context.
Accessibility
- When the user has "reduce motion" enabled at the system level, the FLIP, the enter and exit, and the expansion animations are disabled and a switch lands directly; the DOM structure does not change, and the swap only happens after mount to avoid a hydration gap
- The grid uses
role="grid"together witharia-rowcount/aria-colcount, with rows and cells marked ascolumnheader,rowheader, andgridcelland given their matching indexes - Track toggles are
aria-pressedtoggle buttons; the speaker filter is a single-selectrole="radiogroup"; session cards are buttons carryingaria-expanded/aria-controls, and the expanded content is markedrole="region" - Every decorative icon and placeholder is marked
aria-hidden, and everything is keyboard focusable with afocus-visibleoutline
Widget Board
An iOS home-screen widget grid — frosted glass cards in sm/md/lg size tiers, an edit mode where everything wiggles and can be removed, and a Smart Stack that pages on a timer.
Contact Sheet Gallery
A grid gallery laid out like a photographic contact sheet, complete with sprocket-hole film edges and frame numbers; hover circles a frame in grease pencil, and the selected frame enlarges into a work print with a handwritten annotation.