Task pills
NewA to-do list whose status pills move a little: a breathing dot, a sweeping clock, a flickering flame.
task-pills/task-pills.tsx- Design Crit, Now
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 day list (three to seven rows) where each row’s status matters as much as its title.
- Mixing kinds in one list: an event happening now, a task past its due date, a daily habit.
- A home screen or widget where checking a row off should feel like a small reward.
- Statuses short enough for a pill: one or two words, or a number with an icon.
When not to use
- Long or dense lists, sorted and filtered. Use a table or a plain checklist: the idle motion of dozens of pills would be noise.
- Statuses that need a sentence or several fields. Use a row with a second line, or a card.
- Tasks with subtasks, assignees or priorities. Use a full task row or a board.
- A single yes or no setting. Use a switch or a checkbox on its own.
You can add this component with the shadcn CLI or by hand:
Into components/task-pills of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/task-pills.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19 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
import { TaskPills } from './task-pills';export function Today() { return <TaskPills autoplay={false} onToggle={(id, done) => console.log(id, done)} />;}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.
Each row of this to-do list keeps its status in a soft pill: Now for an event in progress, when an overdue task was due, a streak for a habit. Every pill has a small idle motion of its own. Checking a task turns its pill into Done, and checking a habit rolls the streak up with a burst of sparks.
- All sizes are in one unit, --u, taken from the stage’s width with container units; a pill is 34 units tall, and its type, tracking, paddings and glyphs scale with it.
- The idle motion is CSS keyframes of transform and opacity on small boxes around static SVG drawings: the dot breathes and sends out a ripple, the clock’s minute hand sweeps a full turn with the speed lines trailing, the flame and its core flicker out of step, and sparks rise at staggered times. It pauses while the list is off screen, and a paused animation holds its first frame, the still pose, which is also what shows before JavaScript runs.
- The task’s Due and Done pills sit in the same place, aligned to the right edge. On a toggle, the wider pill is clipped to the narrower one’s width while the colours cross-fade, and the old words go before the new ones come in.
- Checking fills the box with the row’s colour, draws the check from left to right and strikes the title through, with CSS transitions. For the habit, the old digit rolls out of a fixed one-digit box while the new one rolls in, the flame flares up and ten sparks burst out of it.
- Every colour is a light-dark() token on the stage, so it follows the page’s color-scheme, or the colorScheme prop; the marks cut into the pills (the clock’s hands, the Done check) take each pill’s own background, so they still read as cut out at night.
| Prop | Type | Default | Description |
|---|---|---|---|
items | TaskItem[] | Design Crit, Finish portfolio, Pushups ×100 | The rows at first: an event (a Now pill), tasks (when they were due) and habits (a streak). |
onToggle | (id: string, done: boolean) => void | — | Called when the reader checks or unchecks a task or a habit, with its id. |
ambient | boolean | true | The pills’ idle motion: the breathing dot, the sweeping clock, the flickering flame. |
autoplay | boolean | true | While on screen and untouched, a ghost pointer checks the task and the habit, then resets. |
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. |
className | string | — | 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.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type FocusEvent, type Ref,} from 'react';import { DemoPointer, runDemo } from './demo-pointer';import { BoxGlyph, CalendarGlyph, CheckGlyph, ClockFace, DashedBoxGlyph, DoneGlyph, FilledBoxGlyph, FlameCoreGlyph, FlameGlyph,/* Task pills (task-pills.tsx): a white stage with the list in its middle, or at night a near-black one. Every measure in the list is a number of units, --u (a pill is 34 of them tall), which follows the stage's width. Colours are the tokens below, light-dark(light, dark): the component takes the color-scheme it inherits from the page (light unless the page says otherwise), or the one its colorScheme prop sets (data-color-scheme). Each pill has a soft background and an ink; the white marks cut into its drawings (the clock's hands, the done check, the flame's heart) take the pill's background at night, so they still read as cut out. Memory and layout. The stage is one layout boundary, its height set by its width, so nothing that changes inside it lays out the page around it. What moves is small boxes around static drawings, by transforms and opacity: the compositor plays the idle motion on layers a few px wide, and it pauses while the list is off screen (data-live). */.stage { /* The stage and its edge. */ --surface: #fff; --line: rgb(0 0 0 / 0.06); /* A title; the habit's and a done one's; the checkboxes' outlines, plain and dashed. */ --ink: #0d0d0d; --ink-muted: #ababab; --box: #d1d1d1; --box-dashed: #d9d9d9;/* * The list's drawings, as static SVGs. Nothing inside them ever changes: whatever moves (a clock * hand, the flame, a check being drawn) is an HTML box around a drawing, moved by transforms or * revealed by a clip. Their colours are the stage's tokens (task-pills.module.css): most take * the colour of the box they sit in. *//** The event's calendar: a rounded square with a solid header and four dots. */export function CalendarGlyph({ className }: { className?: string }) { return ( <svg className={className} viewBox="0 0 21 21" aria-hidden="true"> <rect x="0.85" y="0.85" width="19.3" height="19.3" rx="5.4" fill="none" stroke="currentColor" strokeWidth="1.7" /> <path d="M.94 5.25A5.4 5.4 0 0 1 6.25.85h8.5a5.4 5.4 0 0 1 5.31 4.4z" fill="currentColor" /> <circle cx="9" cy="9.1" r="1.55" fill="currentColor" /> <circle cx="13.3" cy="9.1" r="1.55" fill="currentColor" />'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, * glide the pointer onto a control and press it, calling the component's own handler as a hand * would. 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. * * Nothing the pointer does while the demo plays needs a layout, because one here in the same * frame as one in the list would make the browser lay out the whole page (see the note at the * top of the stylesheet). So it moves by animations the compositor plays, and the press scales a * box that keeps a transform at rest, since a box that gains or loses one is laid out again. */export type Point = { x: number; y: number };export type Demo = { /** Resolves after `ms`. */ wait: (ms: number) => Promise<void>; /** Glides the pointer onto `target`: its middle, or a point given as shares of its size. */ moveTo: (target: Element | null | undefined, at?: Point) => Promise<void>; /** Presses and lets go; `action` runs as the pointer goes down. */'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 checkable row is a label around a native checkbox: clicking anywhere on the row or pressing Space toggles it, and a visible focus ring sits on the box.
- The pills are hidden from assistive tech; their meaning is part of the accessible name instead (“Finish portfolio, due Yesterday” or “…, done”, “Pushups ×100, 5-day streak”).
- The reader’s checks are announced politely (“Pushups ×100, done. 6-day streak.”); the demo stays silent.
- Under reduced motion there is no idle motion and no demo, and every state change is instant.