Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Event setup

Advanced

An event card edited where it stands: the title turns into a field, the date opens a calendar, the time splits in two, and selected text gets a formatting toolbar.

event-setup/event-setup.tsx
Live · interactive

Setup events

Title
Date
Time
Description
A short weekly check-in where the team lines up on what shipped, what’s blocked, and what’s coming. We’ll walk through the sprint board, call out risks early, and note anything that needs help from other teams.

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

When to use

  • A short record of a few fields (a title, a date, a time, a note) that people read far more often than they change.
  • Changing one detail at a time, in place, without opening a form or leaving the view.
  • A side panel or a popover beside a calendar or a list, where every value is already on screen.
  • A description that needs light formatting (a colour, a highlight, a link, bold) and no more.

When not to use

  • Creating an event from nothing, with every field empty. Use a form with its fields open and one Save.
  • Many fields, or fields that depend on each other (repeats, guests, time zones). Use a full editor.
  • Long documents with headings, lists or pictures. Use a real rich-text editor.
  • Values that must be reviewed together before they count: here each one saves on its own.
  • A read-only summary. Plain text is enough; the pencils promise an edit.

You can add this component with the shadcn CLI or by hand:

Into components/event-setup of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/event-setup.json

Either way it needs React 19.2 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

Usage

team-event.tsx
'use client';import { EventSetup } from './event-setup';export function TeamEvent() {  return (    <EventSetup      defaultTitle="Design Meet"      defaultDate="2026-04-24"      defaultStart="08:00"      defaultEnd="22:00"      autoplay={false}      onChange={(event) => console.log(event)}    />  );}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

A card of an event’s details, each edited in place. Press the title and a grey field grows around it, the date springs a month calendar out of its corner, the time splits into a field per end, and selecting text in the description brings up a dark toolbar with colours, highlights, a link, bold, italic and strikethrough. Saved, each field folds back into text.

  • Each value is a button that turns into its editor. The switch is played FLIP: pieces are measured before and after, the text glides into place in the field, and the grey shape behind it stretches by its offsets and fades by its colour, so it never becomes a layer redrawn at every size.
  • The time splits the same way: “8:00 AM – 10:00 PM” comes apart into two small fields, each time and its AM or PM gliding into one, while the dash fades and a check pops in.
  • The calendar is a month grid, Monday first, with the picked day as a blue square that glides between days. It hangs above the date and springs open from its corner, and a turned month slides in from the side it comes from.
  • The description is a contentEditable paragraph kept as a list of runs: pieces of text with their marks. The browser types into it and the runs are read back from the DOM. A format cuts the runs at the selection’s edges, rewrites the HTML and puts the selection back by its character offsets. Enter, rich pastes and the browser’s own formatting are turned away, and undo works on the runs.
  • The toolbar is centred over the selection and glides to the next one. The demo’s selection is painted with the CSS Custom Highlight API, the way the browser paints its own, so the demo never touches the page’s selection or focus.
  • Every colour is a light-dark() token on the root, shared by the calendar and the description. The description writes its text colours and highlights as references to those tokens, so formatted text changes with the scheme, and the morphs read the fields’ grey from the computed style as each one starts.
PropTypeDefaultDescription
defaultTitlestring'Design Meet'The event’s name.
defaultDatestring'2026-04-24'Its day, as “YYYY-MM-DD”.
defaultStartstring'08:00'When it starts, as “HH:MM” on a 24-hour clock.
defaultEndstring'22:00'When it ends, as “HH:MM”.
defaultDescriptionstring | Run[]DESCRIPTIONThe description as plain text (by default, a team check-in), formatted in the card, or as runs of text with their marks: bold, italic, strike, colour, highlight, link.
defaultStateEventSetupState—Opens in a state, with the demo off: an editor open (editing), a phrase selected with the toolbar over it (selection) and one of its menus open (menu), or the card closed.
onChange(details: EventDetails) => void—Called after every saved change with the title, date, start, end and the description’s runs.
onClose() => void—Called when the card is closed; it folds into a “Setup events” button that opens it again.
autoplaybooleantrueWhile on screen and untouched, a ghost pointer edits the title, formats two phrases, picks a date and moves the start time. Set it to false in a product.
colorScheme'light' | 'dark' | 'system'—Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
classNamestring—Extra class for the root, a stage of its own.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

event-setup.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useEffectEvent,  useId,  useLayoutEffect,  useRef,  useState,  type CSSProperties,} from 'react';import { addDays, isoOf, longDate, parseClock, parseDay, shortDate, type Day } from './dates';import { DatePicker } from './date-picker';import { Description, type DescriptionHandle } from './description';import { GhostPointer, runDemo, type Demo } from './ghost-pointer';import { CloseIcon, DateIcon, DescriptionIcon, PencilIcon, TimeIcon, TitleIcon } from './icons';import { normalize, type Run, type Span } from './rich-text';import { spring } from './spring';import { draftOf, TimeField, type TimeDraft } from './time-field';import { TitleField, type TitleDemo } from './title-field';import { useAutoplay } from './use-autoplay';import styles from './event-setup.module.css';
event-setup.module.css
/* Event setup (event-setup.tsx): a white stage with the card on it, set low enough to leave room   above it for the calendar, which opens upward; at night a near-black stage, the card's frame, its   panel and the calendar each a step lighter. The value ↔ field morphs are played from the   component (flip.ts); this sheet gives each state its layout. Its colours are tokens on the root,   which the calendar's and the description's sheets use too. */.root {  /* Its colours, light and dark: each light-dark() pair resolves by the color-scheme the root     inherits from the page, or the one the colorScheme prop sets (below). */  /* The stage; the card's panel; its frame; the fields' grey shape (flip.ts reads it). */  --surface: #fff;  --surface-2: #fff;  --rim: #f7f7f7;  --field: #f6f6f6;  /* The heading and the caret; the text; the labels; their icons; the pencil; a field's buttons,     under the pointer; AM/PM, under the pointer; the description's placeholder. */  --ink: #141414;  --text: #3a3a3a;  --ink-muted: #7c7c7c;  --icon: #a8a8a8;  --pencil: #9a9a9a;  --field-icon: #9d9d9d;  --field-icon-hover: #4a4a4a;  --meridiem: #a3a3a3;
title-field.tsx
'use client';import {  useEffect,  useEffectEvent,  useLayoutEffect,  useRef,  type KeyboardEvent,  type Ref,} from 'react';import { play, snapshot, type Snapshot } from './flip';import { CheckIcon, CrossIcon, PencilIcon } from './icons';import type { Spring } from './spring';import styles from './event-setup.module.css';/* * The title's value, and the field it turns into. Pressed, the text stays put while a grey pill * grows around it and the save and cancel buttons pop in after it; saved, the pill folds back into * the text (see flip.ts). The field grows with what is typed in it, and keeps the text selected * at first so that typing replaces it. Enter, the check or a press anywhere else saves; Escape or * the cross cancels. *//** The demo's touches: the pointer over the value, the text selected, or a caret after it. */
time-field.tsx
'use client';import {  useEffect,  useEffectEvent,  useLayoutEffect,  useRef,  type KeyboardEvent,  type Ref,} from 'react';import { clockOf, parseClock } from './dates';import { play, snapshot, type Snapshot } from './flip';import { CheckIcon, PencilIcon } from './icons';import type { Spring } from './spring';import styles from './event-setup.module.css';/* * The time's value, and the two fields it splits into. Pressed, "8:00 AM – 10:00 PM" comes apart: * each time glides into a small grey field of its own, the dash fades, and a check pops in at the * end (see flip.ts). In a field, digits type a time ("9", "930", "21:30"), the arrow keys move it * by a quarter of an hour (Shift: an hour), and the AM/PM button switches its half of the day. * Enter, the check or a press anywhere else saves; Escape cancels. */
flip.ts
import { easingOf, type Spring } from './spring';/* * The morphs between a value and its editor, played FLIP: before the switch, every piece marked * data-flip="key" is measured where it is on screen; after it, where the new layout put the piece * with the same key; then each plays from the old place to the new one on a spring. Pieces marked * data-flip-shape are the grey shapes behind fields: they stretch from their old box to the new * one, and fade in or out when one of the two is a shape that does not show * (data-flip-shape="hidden"). A shape moves by its offsets and fades by its colour rather than by * a transform and its opacity: that way it stays painted in place and never becomes a layer of * its own, which would be redrawn at every size it passes through. A piece marked * data-flip-enter that was not there before (a button of the editor, the dash between two times) * fades and grows in. */type Piece = { rect: DOMRect; shown: boolean };export type Snapshot = Map<string, Piece>;const pieces = (root: HTMLElement) => root.querySelectorAll<HTMLElement>('[data-flip]');/** Where each piece under `root` is now. */export function snapshot(root: HTMLElement): Snapshot {  const map: Snapshot = new Map();  for (const element of pieces(root)) {
date-picker.tsx
'use client';import {  useEffect,  useEffectEvent,  useLayoutEffect,  useRef,  useState,  type CSSProperties,  type KeyboardEvent,  type RefObject,} from 'react';import {  addDays,  addMonths,  isoOf,  longDate,  MONTHS,  monthGrid,  sameDay,  WEEKDAY_SHORT,  weekdayOf,  type Day,} from './dates';
date-picker.module.css
/* The calendar (date-picker.tsx): a white popover above the date's value, right-aligned with it   (dark grey at night, its colours tokens of the card's root). It grows out of the value's corner   on a spring and fades away when it closes. */.popover {  --cell: 28px;  position: absolute;  z-index: 30;  right: 0;  bottom: calc(100% + 6px);  box-sizing: border-box;  width: calc(7 * var(--cell) + 22px);  padding: 16px 11px 12px;  border-radius: 14px;  background: var(--popover);  box-shadow:    0 0 0 1px var(--popover-edge),    0 2px 5px var(--shadow-near),    0 16px 36px -8px var(--shadow-popover);  color: var(--popover-ink);  font-size: 13px;  line-height: 16px;  pointer-events: auto;  transform-origin: 80% 100%;
dates.ts
/* * Calendar days and clock times, without time zones: a day is a year, a month (0 to 11) and a * date, and arithmetic goes through UTC so that no daylight-saving change can shift it. Weeks * start on Monday. */export type Day = { year: number; month: number; date: number };export const MONTHS = [  'January',  'February',  'March',  'April',  'May',  'June',  'July',  'August',  'September',  'October',  'November',  'December',];const WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];/** The calendar's column headers. */
description.tsx
'use client';import {  useEffect,  useEffectEvent,  useImperativeHandle,  useLayoutEffect,  useRef,  useState,  type CSSProperties,  type KeyboardEvent,  type Ref,} from 'react';import { FormatToolbar, type Toggle } from './format-toolbar';import {  format,  fromDOM,  HIGHLIGHTS,  marksOf,  rangeOf,  replace,  select,  selectedSpan,  serialize,
format-toolbar.tsx
'use client';import {  useLayoutEffect,  useRef,  useState,  type CSSProperties,  type KeyboardEvent,  type Ref,} from 'react';import { CheckIcon, LinkIcon } from './icons';import { HIGHLIGHTS, TEXT_COLORS, type Marks, type Swatch } from './rich-text';import styles from './description.module.css';/* * The formatting toolbar that floats over a selection: text colour, highlight, link, bold, * italic and strikethrough. The two colours open a row of swatches above the toolbar; the link * turns the toolbar into an address field. It never takes the focus from the text on a press, so * the selection stays; from the keyboard it is a toolbar (Tab from the text reaches it, the arrow * keys move along it, Escape goes back to the text). */export type Toggle = 'bold' | 'italic' | 'strike';type Menu = 'color' | 'highlight' | 'link' | null;
description.module.css
/* The description (description.tsx) and the toolbar that floats over its selection   (format-toolbar.tsx). The text sits on a sheet that turns grey while it is being edited. The   colours are tokens of the card's root; the toolbar is dark in both schemes. */.description {  position: relative;  margin-top: 6px;}.paper {  position: relative;  padding: 6px 10px;  border-radius: 10px;  transition: background-color 0.2s ease;}.paper[data-active] {  background: var(--field);}.editor {  position: relative;  z-index: 1;  min-height: 20px;  color: var(--text);  white-space: pre-wrap;
rich-text.ts
/* * The description's text model. A paragraph is a list of runs: pieces of text that share the same * marks (bold, italic, strikethrough, a text colour, a highlight, a link). The editor writes the * runs as HTML, reads them back from the DOM after the browser has typed into it, and formats a * selection by cutting the runs at its edges and changing the marks of the runs in between. * Positions are character offsets into the paragraph, so a selection survives a fresh render. */export type Marks = {  bold?: boolean;  italic?: boolean;  strike?: boolean;  /** The id of one of TEXT_COLORS. */  color?: string;  /** The id of one of HIGHLIGHTS. */  highlight?: string;  /** A link's address. */  href?: string;};export type Run = { text: string; marks: Marks };/** A stretch of the paragraph, in characters: from `start` up to, not including, `end`. */export type Span = { start: number; end: number };
icons.tsx
/* * The card's icons: outlined, drawn on an 18 × 18 grid with round caps, in the current colour. * They are decorative; the controls that hold them carry the labels. */type IconProps = { className?: string };const outline = {  viewBox: '0 0 18 18',  fill: 'none',  stroke: 'currentColor',  strokeWidth: 1.4,  strokeLinecap: 'round' as const,  strokeLinejoin: 'round' as const,  'aria-hidden': true,};/** A note with a ribbon marker. */export function TitleIcon({ className }: IconProps) {  return (    <svg className={className} {...outline}>      <rect x="3.7" y="2.7" width="10.6" height="12.6" rx="2" />      <path d="M8.2 2.9v4.3l1.4-1 1.4 1V2.9" />    </svg>
ghost-pointer.tsx
'use client';import type { Ref } from 'react';import styles from './event-setup.module.css';/* * The demo's ghost pointer, and the timeline that moves it. A demo is a script of awaited steps: * wait, glide the pointer onto a control, press it and call the component's own handler, as a * click would. The pointer takes the shape a real one would: an arrow, a hand over buttons, an * I-beam over text. Stopping the demo (the reader takes over, or it scrolls away) ends the step in * progress at once: its promise rejects, which ends the script right there, and the pointer fades. */export type Shape = 'arrow' | 'hand' | 'text';/** A point in the stage's own pixels. */export type Point = { x: number; y: number };export type Demo = {  /** Resolves after `ms`. */  wait: (ms: number) => Promise<void>;  /**   * Glides the pointer onto `target`: an element (its middle, or a point given as shares of its   * size) or a point on the stage. `ms` sets the glide's length; by default it follows the
spring.ts
/* * Springs as CSS easings. A spring pulls a value to its target and lets it settle: fast at first, * then slowing down, overshooting a little and coming back if it is bouncy. CSS `linear()` draws * any curve through a list of points, so the spring's motion is sampled once, and the same curve * serves CSS transitions and the Web Animations API. */export type Spring = {  /** The curve, as a CSS `linear()` easing. */  easing: string;  /** How long it takes to settle, in ms: the duration to play the curve over. */  duration: number;};/** Points sampled along the curve: plenty for a smooth motion. */const SAMPLES = 40;/** * A spring that covers most of the distance in about `response` ms. With a `bounce` of 0 it * settles without overshooting; at 0.3 it overshoots by about 5% and comes back. */export function spring(bounce: number, response: number): Spring {  // The damping ratio (1: no overshoot) and the natural frequency, in radians per second.  const damping = 1 - bounce;
use-autoplay.ts
'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) {  const ref = useRef<T>(null);  const [visible, setVisible] = useState(false);  const [reduced, setReduced] = useState(true);  const [taken, setTaken] = useState(false);  const timer = useRef(0);  useEffect(() => {    const element = ref.current;    if (!element) return;    let onScreen = false;    const update = () => setVisible(onScreen && !document.hidden);    const observer = new IntersectionObserver(      ([entry]) => {        onScreen = entry.isIntersecting;
  • Each value is a labelled button (“Title: Design Meet. Edit”) that opens its editor and moves the focus into it. Enter or a press elsewhere saves, Escape cancels, and the focus goes back to the value. Changes are announced politely (“Date set: Tuesday, April 28, 2026.”).
  • The calendar is a dialog holding a grid. The arrow keys move a day or a week, Page Up and Page Down a month (with Shift, a year), and Home and End go to the ends of the week. Space picks a day and Enter picks and applies. Each day is named by its full date.
  • The description is a multi-line text box named by its label. With text selected, Tab reaches a labelled toolbar whose buttons the arrow keys move along. B, I and S are toggles with aria-pressed, the colours open rows of named swatches, and Escape returns to the text. Cmd or Ctrl with B, I, Shift-X, Z and Shift-Z work in the text.
  • Under reduced motion the editors open at once, nothing glides, and the demo does not play.