Skip to content
Star on GitHub
DatesAgenda
Style
Accent
Sound

Built by DesignMe, free to use under the MIT licence.

Expert design and dev support for startups and enterprises.

Book a call

Agenda

Today's schedule with a live now line and agent prep notes. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.

$npx designme add agenda
TodayMonday, Oct 19
4 events
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add agenda

React

$npx designme add agenda --react

The CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.

02

About

What it is for and what it does.

Agenda lists today's events in time order, with a line that marks where now falls in the day. When a person opens an event, the notes the agent prepared for it slide in underneath, along with an optional action such as joining the call. It suits an assistant that gets people ready for their meetings.

  • When an assistant prepares briefing notes before each meeting
  • When a home screen should show what is left of today at a glance
  • When a person needs a join or open button next to the context for a call
  • When past events should stay visible but step back from the ones still to come
03

Usage

Import it and drive it from your own state.

Import

import { Agenda } from '@/components/designme/agenda/Agenda';

Example

The demo above as a React component, 29 lines you can copy.

'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useRef, useState } from 'react';
import { Agenda, type AgendaItem } from './Agenda';
import { useStory } from '../lib/useScript';

const P = {
  MK: { initials: 'MK', color: 'var(--dm-av-l)' }, JO: { initials: 'JO', color: 'var(--dm-av-m)' }, AR: { initials: 'AR', color: 'var(--dm-av-p)' },
  SL: { initials: 'SL', color: 'var(--dm-av-t)' }, DK: { initials: 'DK', color: 'var(--dm-av-b)' }
};
const ITEMS: AgendaItem[] = [
  { key: 'standup', time: '9:00', title: 'Standup', detail: '15 min, Design team', people: [P.MK, P.JO, P.AR], past: true },
  { key: 'demo', time: '11:30', title: 'Agent demo', detail: '30 min, Zoom', people: [P.SL, P.MK],
    notes: ['Demo flow: pricing audit, then the token export', 'Sam asked about cost limits last time, the cap is now $5'], action: { label: 'Join in 48 min' } },
  { key: 'pricing', time: '1:00', title: 'Pricing workshop', detail: '90 min, Room 4', people: [P.JO, P.AR, P.DK, P.MK],
    notes: ['Trial to paid is up 8.1 pts since Sep 18', 'Open question: show monthly price crossed out on mobile too?', '3 frames ready to review in Figma'] },
  { key: 'maya', time: '4:00', title: '1:1 with Maya', detail: '30 min', people: [P.MK],
    notes: ['She wanted feedback on the checkout copy', 'Library publish is waiting on her approval'] }
];

export default function AgendaDemo() {
  const [open, setOpen] = useState<string | null>(null);
  // a moment in, the agent opens its notes for the next big meeting
  // the preview opens one meeting on its own, unless you take over first
  const ref = useRef<HTMLDivElement>(null);
  useStory(ref, async ({ wait }) => { await wait(1000); setOpen('pricing'); });
  return <div ref={ref} style={{ width: '100%' }}><Agenda items={ITEMS} subtitle="Monday, Oct 19" now="10:42" open={open} onOpenChange={setOpen} /></div>;
}
04

Props

Everything Agenda takes.

PropTypeWhat it does
itemsrequiredAgendaItem[]
titlestringLike "Today"
subtitlestringUnder the title, like "Monday, Oct 19"
countstringThe tag in the corner. Defaults to "4 events"
nowstringThe time on the now line, like "10:42". Leave it out for no line
openstring | nullThe item whose notes are open, by key. Leave it out to let each item toggle
defaultOpenstring | null
onOpenChange(key: string | null) => void
classNamestring
05

Code

The full source, as the CLI installs it.

Agenda.tsxReact · 101 lines
'use client';
import type { ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { Avatar, enter } from '../lib/motion';
import '../designme.css';
import './agenda.css';

export interface AgendaPerson {
  /** Like "MK" */
  initials: string;
  /** The avatar's colour, like 'var(--dm-av-l)' */
  color: string;
}

export interface AgendaItem {
  /** Identifies the item for `open`. Defaults to its title */
  key?: string;
  /** Like "11:30" */
  time: string;
  /** Like "Agent demo" */
  title: string;
  /** The quiet line under the title, like "30 min, Zoom" */
  detail?: string;
  people?: AgendaPerson[];
  /** Already over: the item fades back, and the now line sits after the last one */
  past?: boolean;
  /** What the agent prepared, shown when the item opens. Items without notes don't open */
  notes?: ReactNode[];
  /** A button at the end of the notes, like "Join in 48 min" */
  action?: { label: string; onClick?: () => void };
}

export interface AgendaProps {
  items: AgendaItem[];
  /** Like "Today" */
  title?: string;
  /** Under the title, like "Monday, Oct 19" */
  subtitle?: string;
  /** The tag in the corner. Defaults to "4 events" */
  count?: string;
  /** The time on the now line, like "10:42". Leave it out for no line */
  now?: string;
  /** The item whose notes are open, by key. Leave it out to let each item toggle */
  open?: string | null;
  defaultOpen?: string | null;
  onOpenChange?: (key: string | null) => void;
  className?: string;
}

const keyOf = (item: AgendaItem) => item.key ?? item.title;

/** Today's events in order, with a line for now. Open one to see what the agent prepared for it. */
export function Agenda({ items, title = 'Today', subtitle, count, now, open, defaultOpen = null, onOpenChange, className }: AgendaProps) {
  const [opened, setOpened] = useControllable(open, defaultOpen, onOpenChange);
  // the now line goes after the last item that is over
  const lastPast = items.reduce((at, item, i) => item.past ? i : at, -1);
  // rows and the now line stagger in together, in the order they sit
  let n = 0;
  const row = (item: AgendaItem) => {
    const k = keyOf(item), isOpen = opened === k && !!item.notes?.length;
    const notes = item.notes ?? [];
    return (
      <button key={k} type="button" className={cx('dm-agx-e', item.past && 'dm-past', isOpen && 'dm-open', enter().className)} style={enter(n++ * 60).style}
        aria-expanded={notes.length ? isOpen : undefined} onClick={() => setOpened(isOpen || !notes.length ? null : k)}>
        <span className="dm-tm dm-num">{item.time}</span>
        <span className="dm-bar" />
        <span className="dm-t">
          <b>{item.title}</b>
          {item.detail && <span>{item.detail}</span>}
          {item.people?.length ? <span className="dm-who">{item.people.map((p, j) => <Avatar key={j} initials={p.initials} color={p.color} />)}</span> : null}
        </span>
        {isOpen && (
          <div className="dm-agx-prep">
            {notes.map((t, j) => <div key={j} {...enter(j * 60)}><Icon name="spark" size="xs" /><span>{t}</span></div>)}
            {item.action && <span className="dm-pill dm-accent dm-sm dm-agx-join dm-enter" style={enter(notes.length * 60).style}
              onClick={e => { if (item.action?.onClick) { e.stopPropagation(); item.action.onClick(); } }}>{item.action.label}</span>}
          </div>
        )}
      </button>
    );
  };

  return (
    <div data-dm="" className={cx('dm-agx dm-panel', className)}>
      <div className="dm-agx-h">
        <div><b>{title}</b>{subtitle && <span className="dm-muted">{subtitle}</span>}</div>
        <span className="dm-tag dm-b">{count ?? `${items.length} ${items.length === 1 ? 'event' : 'events'}`}</span>
      </div>
      <div className="dm-agx-list">
        {items.flatMap((item, i) => {
          const out = [row(item)];
          if (now && i === lastPast) out.push(<div key="now" className="dm-agx-now dm-enter" style={enter(n++ * 60).style}><i />{now}</div>);
          return out;
        })}
      </div>
    </div>
  );
}
agenda.cssReact · 31 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Agenda ---------- */
.dm-agx{padding:var(--dm-s-3);display:flex;flex-direction:column;gap:var(--dm-s-2)}
.dm-agx-h{display:flex;align-items:center;justify-content:space-between;padding:2px var(--dm-s-1)}
.dm-agx-h>div{display:flex;flex-direction:column;font-size:13.5px}
.dm-agx-h b{font-weight:500}
.dm-agx-h .dm-muted{font-size:12px}
.dm-agx-list{display:flex;flex-direction:column;gap:var(--dm-s-1);position:relative}
.dm-agx-e{display:grid;grid-template-columns:52px 3px 1fr;gap:10px;padding:var(--dm-s-2) 6px;border-radius:10px;text-align:left;width:100%;transition:background .2s,opacity .3s}
@media (hover:hover) and (pointer:fine){.dm-agx-e:hover{background:var(--dm-row)}}
/* the open event needs no fill of its own: it sits on the panel's white and its prep notes show it is open */
.dm-agx-e.dm-open{background:var(--dm-surface)}
@media (hover:hover) and (pointer:fine){.dm-agx-e.dm-open:hover{background:var(--dm-surface)}}
/* a past event: its title steps down to muted and its bar fades, the text stays readable */
.dm-agx-e.dm-past .dm-t b{color:var(--dm-muted)}.dm-agx-e.dm-past .dm-bar{opacity:.5}
.dm-agx-e .dm-tm{font-size:12px;color:var(--dm-muted);padding-top:1px}
.dm-agx-e .dm-bar{border-radius:2px;background:var(--dm-accent-mark,var(--dm-accent))}
.dm-agx-e .dm-t{display:flex;flex-direction:column;font-size:13px;min-width:0}
.dm-agx-e .dm-t b{font-weight:500}
.dm-agx-e .dm-t span{font-size:12px;color:var(--dm-muted)}
.dm-agx-e .dm-who{display:flex;margin-top:6px}
.dm-agx-e .dm-who .dm-av{width:20px;height:20px;font-size:8.5px;color:#fff;margin-right:-2px;box-shadow:0 0 0 1.5px var(--dm-surface)}
.dm-agx-prep{grid-column:3;font-size:12.5px;line-height:1.5;color:var(--dm-fg2);display:flex;flex-direction:column;gap:5px;padding-top:var(--dm-s-2)}
.dm-agx-prep div{display:flex;gap:7px;align-items:flex-start}
.dm-agx-prep svg{color:var(--dm-accent-fg,var(--dm-accent));margin-top:3px;flex:none}
.dm-agx-now{display:flex;align-items:center;gap:6px;margin:2px 0;font-size:11px;color:var(--dm-accent-fg,var(--dm-accent));font-weight:500;padding-left:6px}
.dm-agx-now::after{content:"";flex:1;height:2px;background:var(--dm-accent-mark,var(--dm-accent));border-radius:1px}
.dm-agx-now i{width:7px;height:7px;border-radius:50%;background:var(--dm-accent-mark,var(--dm-accent));animation:dm-agnow 2s ease-in-out infinite}
@keyframes dm-agnow{50%{box-shadow:0 0 0 5px var(--dm-accent-bg)}}
.dm-agx-join{margin-top:var(--dm-s-2);align-self:flex-start}
agenda.htmlHTML · 9 lines
<div class="card wide" data-c="agenda">
  <div class="stage-in">
    <div class="agx panel">
      <div class="agx-h"><div><b>Today</b><span class="muted">Monday, Oct 19</span></div><span class="tag b agx-n">4 events</span></div>
      <div class="agx-list"></div>
    </div>
  </div>
</div>
agenda.cssHTML · 30 lines
/* ---------- Agenda ---------- */
.agx{padding:var(--s-3);display:flex;flex-direction:column;gap:var(--s-2)}
.agx-h{display:flex;align-items:center;justify-content:space-between;padding:2px var(--s-1)}
.agx-h>div{display:flex;flex-direction:column;font-size:13.5px}
.agx-h b{font-weight:500}
.agx-h .muted{font-size:12px}
.agx-list{display:flex;flex-direction:column;gap:var(--s-1);position:relative}
.agx-e{display:grid;grid-template-columns:52px 3px 1fr;gap:10px;padding:var(--s-2) 6px;border-radius:10px;text-align:left;width:100%;transition:background .2s,opacity .3s}
@media (hover:hover) and (pointer:fine){.agx-e:hover{background:var(--row)}}
/* the open event needs no fill of its own: it sits on the panel's white and its prep notes show it is open */
.agx-e.open{background:var(--surface)}
@media (hover:hover) and (pointer:fine){.agx-e.open:hover{background:var(--surface)}}
/* a past event: its title steps down to muted and its bar fades, the text stays readable */
.agx-e.past .t b{color:var(--muted)}.agx-e.past .bar{opacity:.5}
.agx-e .tm{font-size:12px;color:var(--muted);padding-top:1px}
.agx-e .bar{border-radius:2px;background:var(--accent-mark,var(--accent))}
.agx-e .t{display:flex;flex-direction:column;font-size:13px;min-width:0}
.agx-e .t b{font-weight:500}
.agx-e .t span{font-size:12px;color:var(--muted)}
.agx-e .who{display:flex;margin-top:6px}
.agx-e .who .av{width:20px;height:20px;font-size:8.5px;color:#fff;margin-right:-2px;box-shadow:0 0 0 1.5px var(--surface)}
.agx-prep{grid-column:3;font-size:12.5px;line-height:1.5;color:var(--fg2);display:flex;flex-direction:column;gap:5px;padding-top:var(--s-2)}
.agx-prep div{display:flex;gap:7px;align-items:flex-start}
.agx-prep svg{color:var(--accent-fg,var(--accent));margin-top:3px;flex:none}
.agx-now{display:flex;align-items:center;gap:6px;margin:2px 0;font-size:11px;color:var(--accent-fg,var(--accent));font-weight:500;padding-left:6px}
.agx-now::after{content:"";flex:1;height:2px;background:var(--accent-mark,var(--accent));border-radius:1px}
.agx-now i{width:7px;height:7px;border-radius:50%;background:var(--accent-mark,var(--accent));animation:agnow 2s ease-in-out infinite}
@keyframes agnow{50%{box-shadow:0 0 0 5px var(--accent-bg)}}
.agx-join{margin-top:var(--s-2);align-self:flex-start}
agenda.jsHTML · 23 lines
DM.def('agenda', (r, c) => {
  const E = [
    ['9:00','Standup','15 min, Design team','b',[['MK','var(--av-l)'],['JO','var(--av-m)'],['AR','var(--av-p)']],null,1],
    ['11:30','Agent demo','30 min, Zoom','y',[['SL','var(--av-t)'],['MK','var(--av-l)']],['Demo flow: pricing audit, then the token export','Sam asked about cost limits last time, the cap is now $5'],0],
    ['1:00','Pricing workshop','90 min, Room 4','p',[['JO','var(--av-m)'],['AR','var(--av-p)'],['DK','var(--av-b)'],['MK','var(--av-l)']],['Trial to paid is up 8.1 pts since Sep 18','Open question: show monthly price crossed out on mobile too?','3 frames ready to review in Figma'],0],
    ['4:00','1:1 with Maya','30 min','l',[['MK','var(--av-l)']],['She wanted feedback on the checkout copy','Library publish is waiting on her approval'],0]
  ];
  const list = $('.agx-list', r);
  list.innerHTML = E.map((e, i) => `<button class="agx-e${e[6] ? ' past' : ''}" data-i="${i}"><span class="tm num">${e[0]}</span><span class="bar"></span><span class="t"><b>${e[1]}</b><span>${e[2]}</span><span class="who">${e[4].map(a => AV(a[0], a[1])).join('')}</span></span></button>`).join('');
  const rows = $$('.agx-e', list);
  rows[0].after(html('<div class="agx-now"><i></i>10:42</div>'));
  stagger([...list.children], 60);
  rows.forEach((el, i) => el.onclick = () => {
    const open = el.classList.contains('open'); rows.forEach(x => { x.classList.remove('open'); $('.agx-prep', x)?.remove(); });
    if (open || !E[i][5]) return;
    el.classList.add('open');
    const p = html(`<div class="agx-prep">${E[i][5].map(t => `<div>${I('spark','xs')}<span>${t}</span></div>`).join('')}${i === 1 ? '<span class="pill accent sm agx-join">Join in 48 min</span>' : ''}</div>`);
    el.appendChild(p); stagger([...p.children], 60);
  });
  // the preview opens one meeting on its own, unless you take over first
  c.auto(1000, () => rows[2].click());
});
06

Questions

What people ask about it.

How do I have the agent open its notes for the next meeting on its own?
Control the component with open and onOpenChange, then set open to that item's key when the agent is ready. The example on this page does exactly that a moment after it loads.
Where does the now line go?
It goes after the last item marked past, labelled with the now string you pass. Leave now out and no line is drawn.
Can the notes contain links or formatted text?
Yes. notes is an array of React nodes, so each note can be plain text or your own markup. Each note gets a small spark icon in front.
07

More in Dates

Pickers for single dates and date ranges.

Date pickerA field that opens a calendar, with quick picks and natural language.
Segmented controlSwitch between views of the same thing, with a sliding indicator.
Date range pickerPick a start and end across two months, or use a preset.
Event calendarA week view where the agent finds a free slot for you.