Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Inbox widgets

New

Six settings panels of a mail app in one card language, every slider, dial, pin and heatmap cell working.

inbox-widgets/inbox-widgets.tsx
Live · interactive

Split inbox

Setup 2 of 3

Four slots, sorted as mail lands

Today’s split63 THREADS · 12 OFF
Sorting liveDrag to correct
2,400 THREADS · 40 S

Snoozed

6 threads

Next one back at 11:00IN 1 H 40 M

TodayDrag a pin to move it
NOW
  • Maya KentQ4 budget — final numbers11:00
  • Rui OrtegaVendor contract redlines14:00
4 more this week

Threads

Waiting

3 waiting on you

LONGEST 2.4 D
  • Them
  • You
  • On you

Send later

Best time to landOPENS 9–10
  • LisbonUTC+1
  • Paris · youUTC+2
  • New YorkUTC−4
TUE 09:12

Notifications

9 hQuiet
From
2200
To
0700
  • VIP sendersAlways on
  • Bundle newslettersDaily · 18:00
  • Badge count
  • Chat mentionsPro plan
Next quiet in 9 H 40 M

Inbox load

14 weeks
1,284/ week−8%
Monday
Tuesday
Wednesday
Thursday
Friday
Saturday
Sunday
LessMoreBusiest Tue 9–11
Reply
1h 40m
Inbox zero
6 d
Auto-sorted
64%

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

  • The settings of a mail or messaging app, where each setting is easier to see than to read: a time on a chart, quiet hours on a dial, load on a heatmap.
  • A settings page or dashboard made of independent panels, each closable, that reflow from two columns to one.
  • A setup flow’s step that previews its effect live, as Split inbox does with today’s mail.
  • Any one panel on its own: each is exported by itself (SplitInbox, SendLater, Notifications, Snoozed, InboxLoad, Threads) and carries its own colours.

When not to use

  • Plain preferences that are only on or off. A list of labelled switches says it faster.
  • Exact values typed from somewhere else (an API key, an address). Use text fields.
  • Data the reader must compare across many rows. Use a table, not a stack of cards.
  • A dense admin screen with dozens of settings: six rich panels is about the most a page can hold.

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

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

mail-settings.tsx
import { InboxWidgets, Snoozed } from './inbox-widgets';export function MailSettings() {  return <InboxWidgets autoplay={false} />;}export function SnoozedCard() {  return <Snoozed now={9 * 60 + 20} onWake={(ids) => console.log('back:', ids)} />;}

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 split inbox whose threads you drag between slots, a send-later slider over the hours they open their mail, quiet hours on a 24-hour dial, snoozed threads as pins on today’s timeline, fourteen weeks of inbox load as a heatmap, and the threads waiting on you. Each panel is a component of its own on a shared card; together they lay out as a two-column masonry, or one column on a phone.

  • Every panel is its own component on one card shell (card.tsx): a section named by its title, with the glyph, the tag and the close button. The shell carries the colour tokens and the colorScheme prop, so each panel works on its own as well as in the grid.
  • The grid is two flex columns, 520 px for the wide panels and 300 px for the narrow ones, as they were drawn; under 860 px the columns dissolve (display: contents) and the panels stack in reading order, each sized by container queries on its own card.
  • Figures roll: each digit is a strip of 0–9 behind a one-line window as wide as the digit it shows, moved by a transform over 520 ms. Counts, times, countdowns and waits all use it.
  • Drags follow one pointer on the window from the press until it lifts, without pointer capture, so they keep going outside the control. A dragged thread lifts with a dashed place left behind and tilts with its sideways speed; over the bar it joins the segment under the pointer, over another row that row’s slot, and the bar’s segments grow by their counts (flex-grow, eased).
  • The send time, the quiet hours and the snoozed pins are sliders: the send time snaps to the minute along a 24-hour track, the quiet hours to the quarter hour by the pointer’s angle round the dial (midnight at the top), and the pins to the quarter hour, never before now. The arc is one SVG path redrawn from the two values.
  • The heatmap reads the tooltip’s place from the cell it is about, hangs it under the cell (over it late in the week) and joins them by a dotted line. Its legend’s steps show one level at a time.
  • The tray and the rack are drawn in code: a few boxes in 3D, projected at one dimetric slant and painted back to front. The blue envelope slides into its slot when a thread is sorted (Web Animations API); the hung letters sway on their pegs, each on its own phase, and a woken thread’s letter drops off.
  • While on screen and untouched, a demo pointer plays the panels in view, in turn: it drags a thread to another slot, slides the send time and looks down the zone list, turns the start of the quiet hours and sends a test, moves a snoozed pin an hour, reads two days of the heatmap and marks a thread replied. It drives them with pointer events of its own, which are untrusted and never hand the demo over; any real input does.
  • Every colour is a light-dark() token: by night the near-black cards on black of the original, by day the same cards in white on a pale grey, with the same blue, orange and green.
PropTypeDefaultDescription
autoplaybooleantrueInboxWidgets: while on screen and untouched, a demo pointer plays the panels in view, in turn.
defaultClosedPanelId[]—InboxWidgets: the panels folded into their pills at first ('split', 'send', 'notif', 'snooze', 'load' or 'threads'). Given, the demo pointer does not play.
colorScheme'light' | 'dark' | 'system'—Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme. Every panel takes it too.
onClose() => void—Each panel: called by its round close button; without it the button is not shown. In the grid, a closed panel folds into a pill that brings it back.
slots, threadsSlot[], SortedThread[]—SplitInbox: the four slots with their counts and whether each is on, and the live list. onSlotsChange, onMove, onBack and onTurnOn report what the reader does.
defaultTurnedOnbooleanfalseSplitInbox: opens with the split already turned on, its button reading On.
days, zones, opens, defaultTimeSendDay[], Zone[], number[], number—SendLater: the day chips, their time zones, opens per hour (24 values; the four highest are blue) and the starting time in minutes. defaultZonesOpen shows the zone list open; onSchedule gets the day, minutes and zone.
defaultFrom, defaultTo, vips, nownumber, number, Vip[], number—Notifications: the quiet hours in minutes past midnight, the VIP senders, and the time now for the countdown. onChange gets every setting; onSendTest is called by Send test.
threads, nowSnoozed[], number—Snoozed: the snoozed threads (today’s as minutes past midnight, later ones with a day) and the time now. onChange, onWake and onRules report what the reader does.
load, summarynumber[][], LoadSummary—InboxLoad: threads per day (seven rows, Monday first, one column per week), and the weekly figure, its change, the busiest hours and three stats.
defaultOnlynumber | nullnullInboxLoad: one level of the heatmap (0 to 4) shown alone at first, the others dimmed, as if picked in the legend.
threads, shownWaitingThread[], number—Threads: every open thread with its messages (from them or you, days ago); the first `shown` are drawn and the heading counts them all. onReply reports a row marked replied.
defaultRepliedstring[][]Threads: the ids of the threads already marked replied at first.
classNamestring—Extra class for the root (or the panel’s card).

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

inbox-widgets.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';import cardStyles from './card.module.css';import { DemoPointer, runDemo, type Demo, type Point } from './demo-pointer';import { InboxLoad } from './inbox-load';import { Notifications } from './notifications';import { Snoozed } from './snoozed';import { SendLater } from './send-later';import { SplitInbox } from './split-inbox';import { Threads } from './threads';import { useAutoplay } from './use-autoplay';import type { ColorScheme } from './card';import styles from './inbox-widgets.module.css';export { SplitInbox } from './split-inbox';export { SendLater } from './send-later';export { Notifications } from './notifications';export { Snoozed } from './snoozed';export { InboxLoad } from './inbox-load';export { Threads } from './threads';
inbox-widgets.module.css
/* Inbox widgets (inbox-widgets.tsx): the six panels on the page's colour, in two columns (520 px   for the wide panels, 300 px for the narrow, as they were drawn) when there is room for both,   in one column of up to 520 px under that. The columns are flex columns, a masonry: each panel   keeps its own height. In one column they dissolve (display: contents) and the panels follow   their reading order.   Memory. Nothing here paints over time but what the panels move by transforms; the demo pointer   moves on a layer of its own. */.root {  position: relative;  container-type: inline-size;  box-sizing: border-box;  width: 100%;  overflow: hidden;  overflow: clip;  padding: 40px 16px;  background: var(--stage);  -webkit-tap-highlight-color: transparent;}.grid {  display: flex;  flex-direction: column;  gap: 16px;
card.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useId, type CSSProperties, type ReactNode } from 'react';import styles from './card.module.css';/* * The shell every panel shares, and the small pieces they have in common. * * - Card: a <section> named by its title, with the header (glyph, title, tag, close button) and *   the panel's body. It carries the colour tokens and the colorScheme prop, so a panel works on *   its own as well as in the grid. * - Roll: a number whose digits roll to their new values, each a strip of 0–9 slid by a transform. * - drag(): follows one pointer from a press until it lifts, on the window, so a drag keeps going *   outside the control, and works the same for the demo's own (untrusted) pointer events. */export type ColorScheme = 'light' | 'dark' | 'system';/** What every panel takes besides its own values. */export type PanelProps = {  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */  colorScheme?: ColorScheme;
card.module.css
/* The shell every panel shares (card.tsx): a rounded card with its header (a blue glyph, the   title, an outlined tag, a round close button), and the pieces the panels have in common: the   small uppercase labels, the monospace figures, the pill buttons, the avatars, the switches and   the rolling digits.   Colours are the tokens on .tokens, light-dark(light, dark) where the two differ. The panels   and the grid that lays them out all carry .tokens, so a panel used on its own has them too. At   night it is the near-black card on a black page; by day the same card in white on a pale grey,   with the same blue, orange and green.   Sizes are in px of the card at its own size (300 px narrow, 520 px wide): the card is a size   container, and its paddings step up when it is wide. */.tokens {  /* The page under the cards, the card and its edge. */  --stage: #ececee;  --card: #fff;  --card-edge: rgb(0 0 0 / 0.08);  --card-shadow: rgb(20 20 30 / 0.06);  /* Sunk areas (the chart, the segmented control, the time fields) and raised ones (the sorting     list, the quiet hours, the timeline, the stats), with their edges. */  --well: #f3f3f5;  --panel: #f7f7f8;  --panel-edge: rgb(0 0 0 / 0.06);
split-inbox.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useRef,  useState,  type CSSProperties,  type KeyboardEvent,  type PointerEvent,} from 'react';import { TrayArt } from './art';import {  Avatar,  BoltIcon,  Card,  CheckIcon,  InboxIcon,  Roll,  SignalIcon,  cardStyles,  drag,  type PanelProps,
split-inbox.module.css
/* Split inbox (split-inbox.tsx). Measured on the card at 520 px: the tray drawing, the heading,   today's split (a label row, the bar, the legend), the live list and the footer. */.art {  width: 216px;  max-width: 64%;  height: auto;  margin: 26px auto 0;}.heading {  margin: 22px 0 0;  font: 500 18.5px/24px var(--font);  letter-spacing: -0.02em;}.splitHead {  display: flex;  align-items: baseline;  justify-content: space-between;  gap: 12px;  margin-top: 12px;}.totals {  color: var(--muted);
send-later.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useId,  useRef,  useState,  type CSSProperties,  type KeyboardEvent,  type PointerEvent,} from 'react';import {  Card,  CheckIcon,  ChevronIcon,  ClockIcon,  GlobeIcon,  Roll,  SendIcon,  cardStyles,  clock,  drag,
send-later.module.css
/* Send later (send-later.tsx). Measured on the card at 300 px: the day chips, the chart (bars,   slider, ticks, hours), the time zone button and its list, the footer. */.days {  display: flex;  gap: 6px;  margin-top: 16px;}.day {  flex: 1 1 auto;  display: inline-flex;  align-items: center;  justify-content: center;  height: 28px;  margin: 0;  padding: 0 10px;  border: 0;  border-radius: 999px;  background: var(--control);  color: var(--ink-2);  font: 500 11.7px/1 var(--font);  letter-spacing: 0.01em;  text-transform: uppercase;  white-space: nowrap;
notifications.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useEffectEvent,  useId,  useRef,  useState,  type CSSProperties,  type KeyboardEvent,  type PointerEvent,} from 'react';import {  Avatar,  BellIcon,  Card,  CheckIcon,  PlusIcon,  Roll,  StepperIcon,  cardStyles,  clock,
notifications.module.css
/* Notifications (notifications.tsx). Measured on the card at 300 px: the segmented control, the   quiet hours (the dial, the two time fields, the weekend days), the settings and the footer. */.bell {  display: block;  width: 100%;  height: 100%;  transform-origin: 50% 10%;}.bell[data-ring] {  animation: ring 900ms cubic-bezier(0.3, 0, 0.3, 1);}@keyframes ring {  0%,  100% {    rotate: 0deg;  }  12% {    rotate: 16deg;  }  28% {    rotate: -14deg;  }  44% {
snoozed.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useRef,  useState,  type CSSProperties,  type KeyboardEvent,  type PointerEvent,} from 'react';import { RackArt } from './art';import {  Avatar,  Card,  MoonIcon,  Roll,  SmallClockIcon,  cardStyles,  clock,  drag,  span,  type PanelProps,
snoozed.module.css
/* Snoozed (snoozed.tsx). Measured on the card at 520 px: the rack, the heading and its countdown,   the timeline (pins over a track, ticks, hours), the two rows and the footer. */.art {  width: 226px;  max-width: 66%;  height: auto;  margin: 30px auto 0;}.heading {  display: flex;  flex-wrap: wrap;  align-items: baseline;  column-gap: 0.3em;  margin: 31px 0 0;  font: 500 18.5px/24px var(--font);  letter-spacing: -0.02em;}.countdown {  margin-left: 0.5em;  color: var(--accent-text);  font-size: 13px;  letter-spacing: 0.08em;  white-space: nowrap;
inbox-load.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useRef, useState, type CSSProperties, type KeyboardEvent } from 'react';import { BarsIcon, Card, Roll, cardStyles, type PanelProps } from './card';import styles from './inbox-load.module.css';/* * Inbox load: how much mail came in, day by day, over the last fourteen weeks, as a heatmap * (weekdays down, weeks across), with the weekly average, its change, and three figures. * * - A cell's shade is its level: five steps from none to the most. Hover or focus one and it is *   ringed, and a tooltip under it (or over it, low in the week) gives the day, the week and the *   count, joined to the cell by a dotted line. * - The heatmap is a grid: one tab stop, arrow keys move between days and weeks, Home and End *   go to the ends of a row. * - The legend's steps are buttons: press one to show only the days at that level. */export type LoadSummary = {  perWeek: number;  /** Against the fourteen weeks before, as a share (−0.08 is 8 % less). */  change: number;
inbox-load.module.css
/* Inbox load (inbox-load.tsx). Measured on the card at 300 px: the weekly figure and its badge,   the heatmap (months, weekday letters, 7 × 14 square cells), the legend and the stats. */.summary {  display: flex;  align-items: baseline;  gap: 8px;  margin-top: 17px;}.big {  font: 500 26px/26px var(--font);  letter-spacing: -0.045em;}.badge {  align-self: center;  margin-left: auto;  display: grid;  place-items: center;  height: 23px;  padding: 0 8px;  border-radius: 7px;  background: var(--green-wash);  color: var(--green-ink);  font-size: 12.5px;
threads.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useRef, useState, type CSSProperties } from 'react';import { Card, Roll, ThreadsIcon, cardStyles, type PanelProps } from './card';import styles from './threads.module.css';/* * Threads: who is waiting on whom. Each thread is a line over the last week, with a dot per * message (theirs grey, yours blue). When the last word was theirs, the time since is an orange * bar running to now: the thread is waiting on you. When it was yours, a dotted line: you wait. * * - The heading counts the threads waiting on you (all of them, listed or not) and the longest *   wait; the figures roll when they change. * - Press a row to mark it replied: a blue dot drops in at now, the orange bar draws back, the *   count goes down. Press again to undo. * - The first time it comes on screen, the dots pop in from the left and the bars draw out. */export type Message = { from: 'them' | 'you'; /** Days ago. */ ago: number };export type WaitingThread = { id: string; name: string; topic: string; messages: Message[] };export const WAITING: WaitingThread[] = [
threads.module.css
/* Threads (threads.tsx). Measured on the card at 300 px: the heading with the longest wait, four   lines over the week (a 42 px row each: names, then the line), the days and the legend. The   lines run from the left edge to 34 px short of the right, where now is. */.head {  display: flex;  flex-wrap: wrap;  align-items: baseline;  justify-content: space-between;  gap: 12px;  margin-top: 16.5px;}.heading {  margin: 0;  font: 500 18.5px/24px var(--font);  letter-spacing: -0.02em;  white-space: nowrap;}.longest {  color: var(--orange);  font-size: 13px;  letter-spacing: 0.06em;  white-space: nowrap;}
art.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useRef, type CSSProperties } from 'react';import styles from './art.module.css';/* * The two line drawings: the letter tray of the split inbox and the drying rack of the snoozed * threads. Both are built here from a few boxes in 3D, projected onto the page at the same slant * (a dimetric view: one axis runs down to the right, the other up to the right, heights straight * up), and drawn back to front so that nearer faces hide what is behind them. Faces are filled * with the card's colour and stroked with a thin grey line; one letter in each is blue. * * - The tray: the blue envelope slides down into its slot whenever `drop` changes (a thread was *   sorted), by a Web Animations API transform on its group. * - The rack: every letter sways a little from its peg, each on its own period, by a CSS *   animation on its group (none under reduced motion). The blue one is the next to come back; *   a letter whose thread is woken unpegs and drops away. */type V3 = [number, number, number];type V2 = [number, number];
art.module.css
/* The line drawings (art.tsx): faces in a step off the card's colour, edged with a thin grey   line; the blue letter in the accent, edged lighter. Their colours are the card's tokens plus   the four below. */.art {  --art-face: #fbfbfc;  --art-side: #f4f4f6;  --art-line: #b6b6bc;  --art-blue: #3b82f6;  display: block;  overflow: visible;}@supports (color: light-dark(#fff, #000)) {  .art {    --art-face: light-dark(#fbfbfc, #262626);    --art-side: light-dark(#f4f4f6, #2a2a2a);    --art-line: light-dark(#b6b6bc, #5f5f5f);    --art-blue: light-dark(#3b82f6, #2d5fbd);  }}.face,.side,.blueFace {  fill: var(--art-face);
demo-pointer.tsx
'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, or * glide the pointer somewhere. 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 out. * * Each glide is a curved flight played by the Web Animations API, so nothing runs frame by frame. * The points of the flight are handed to whoever wants to move along with it. The arrow and the hand are HTML boxes * that swap by their visibility: a change of style inside an SVG would lay the SVG out again. */export type Point = { x: number; y: number };/** A glide's path and timing, for an animation that plays along with it. */export type Flight = { points: Point[]; timing: KeyframeAnimationOptions };export type Demo = {  /** Resolves after `ms`. */  wait: (ms: number) => Promise<void>;  /** Glides the pointer to `to`, in the stage's own px, in `ms`; `along` gets the flight. */
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 panel is a section named by its heading. Controls are what they look like: radio groups for the days and the notification modes, sliders (role="slider" with a spoken time) for the send time, the two quiet-hour handles and each snoozed pin, spinbuttons for the hours and minutes, switches, toggle buttons for the slots, the weekend days and the heatmap levels, and a listbox under a button for the time zone.
  • Everything works from the keyboard: arrow keys, Page Up/Down, Home and End on the sliders; ←/→ or 1–4 move a sorted thread to another slot; the heatmap is a grid with one tab stop and arrow keys between days and weeks; the zone list takes ↑/↓, Enter and Escape. Focus is always visible.
  • Changes are announced in polite live regions: a thread moved, a slot turned on or off, a VIP sender added or removed, a test sent, a thread woken or moved, the time scheduled. Rolling figures are read as plain text.
  • The drawings, the demo pointer, the tooltips that repeat a control’s value and the timeline marks are decorative (aria-hidden).
  • Under reduced motion nothing rolls, sways or slides, the lines are drawn at once and the demo never plays.