Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Skeuo tasks

AdvancedNew

A to-do app whose Inbox and Today are trays you can watch fill up, slip by slip.

skeuo-tasks/skeuo-tasks.tsx
Live · interactive

Inbox

Uncategorized, random, or other tasks without a Plan.

  • Book the dentist
  • Return library books
  • Call Grandma back
  • Fix the bike light

Inbox

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 personal to-do app or a showcase of one, where seeing a list fill up is part of the pleasure.
  • Two lists that matter most (a catch-all Inbox and a pick for Today) drawn as trays whose papers show how full each is.
  • Moving tasks in batches between lists: select mode, a count of the picked ones, and one menu of where they go.
  • A landing page or a portfolio that needs a product to feel tactile in a few seconds: the demo adds, moves and ticks tasks on its own.

When not to use

  • A work tracker with owners, dates and statuses in columns. Use a table or a board.
  • A list that runs to hundreds of rows: the rows are placed by index, all drawn, with no virtualisation.
  • Somewhere quiet and dense, like a settings pane: the trays, slips and flights are the point, and they take room.
  • A single checklist inside another screen. Use plain task rows with a checkbox.
  • Anything that needs a task’s notes, dates or tags edited: the new task’s card draws them but only takes a title.

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

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

Terminal
npx shadcn@latest add https://brunodelorence.com/r/skeuo-tasks.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

tasks-panel.tsx
import { SkeuoTasks } from './skeuo-tasks';import type { Task } from './data';export function TasksPanel({ onSave }: { onSave: (tasks: Task[]) => void }) {  return (    <SkeuoTasks source="Mail" incoming="Reply to Sam’s invite" onChange={onSave} autoplay={false} />  );}

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 to-do app with a sidebar and a list. Its two main lists are drawn as trays: a glossy blue one holding paper slips for the Inbox, and a white one (grey plastic at night) with a growing stack of sheets for Today. Add a task and a slip drops in; move tasks to Today and they fly across; tick one off and its slip is flung away.

  • The trays are HTML and CSS: a dark back, a glossy front and paper boxes between them. Each paper is placed by its depth in the pile through a transform, so a new one drops in from above while the others move back and rise.
  • The round + opens a card for the new task over the top of the window; adding it closes the card and drops its slip into the tray.
  • A paper that leaves stays a moment as a ghost to play its exit: lifted out when its task moves, flung up and away when it is done. Moving tasks between the trays sends slips across on an arc, a little over 100 ms apart, and each appears in its new tray and count only once it lands.
  • Rows are placed by their index through a transform, so when one leaves the others slide up with no measuring. Select mode turns them into toggles, and a button counts them and opens what to do with them.
  • Every part that changes during the demo paints on a small layer of its own. Closed panels are only faded out, never hidden by visibility, so their drawings are never restyled and the page is never laid out again.
  • Every colour is a light-dark() token on the root, the trays, their fronts and backs and the paper included, so at night it is the same desk in dim light: darker plastic and dimmed paper rather than grey boxes. It follows the page’s color-scheme, or the colorScheme prop.
PropTypeDefaultDescription
sourcestring'Mail'Where the waiting task comes from, in the banner: “1 task from …”.
incomingstring'Reply to Sam’s invite'The title of the task waiting to be brought in.
defaultStateSkeuoTasksState—Opens in this state instead of the start: list (the list on show), filter ('all' | 'fresh'), selecting and selected (select mode, with these task ids picked), ticked (tasks shown ticked), composing and draft (the new task’s card open, with this title), menu ('list' | 'filter' | 'actions') and imported (the waiting task already in the Inbox). Turns the demo off.
onChange(tasks: Task[]) => void—Called with every task whenever one is added, moved, ticked off or deleted.
autoplaybooleantrueWhile on screen and untouched, a demo pointer adds, selects and moves tasks.
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.

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

skeuo-tasks.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useEffectEvent,  useId,  useLayoutEffect,  useRef,  useState,  type CSSProperties,  type ReactNode,} from 'react';import { INCOMING, LISTS, TASKS, type ListId, type Task } from './data';import { DemoPointer, runDemo, type Demo } from './demo-pointer';import {  ArrowDownIcon,  BellIcon,  BookIcon,  CalendarIcon,  CheckCircleIcon,  ChevronDown,  ChevronRight,
skeuo-tasks.module.css
/* Skeuo tasks (skeuo-tasks.tsx): an app window on a misty grey screen, the sidebar on the left   with the two trays (trays.tsx), the list on the right. On a narrow stage the sidebar keeps only   its trays, above the list.   Memory. A phone keeps the page's pixels in tiles the width of the screen, and a tile painted   again takes more buffers. So every part that changes while the demo plays paints on a small   layer of its own (will-change): the trays, the slips, the list's title, each row's title and   box, the round + and the select bar, the new task's lines, the menus; and each of these layers   keeps one size, as a layer that grows or shrinks is drawn again in new buffers. A layer painted   again holds two buffers, and one painted at every frame (a colour fading) many more, so:   - Safari paints a layer again whenever its opacity or transform changes, and all the layers in     it; not when its translate, scale or rotate change. What moves (rows, slips, sheets, the     card) moves by those.   - A plain colour clipped to rounded corners holds no pixels, and its colour fades without     painting: the rows' backgrounds and the new task's card are drawn so, and their shadows by the     compositor (filter: drop-shadow, as a box-shadow of half the blur), but for the rows' shadow,     too small for Safari to draw so.   - New elements are new layers: rows keep their elements as they leave, and another list's rows     take them over.   Above all, the whole page must never be laid out, as that paints all its tiles again. The stage   is one layout boundary, its height set from its width; its two panes are placed absolutely,   not by a grid, so that a change in one never lays the other out (and paints it again); and no   query container is left around it once the script has measured it (see the end).
trays.tsx
'use client';import {  useEffect,  useEffectEvent,  useLayoutEffect,  useRef,  useState,  type CSSProperties,  type RefObject,} from 'react';import { StarFilled, TrayIcon } from './icons';import type { Task } from './data';import styles from './skeuo-tasks.module.css';/* * The two trays at the top of the sidebar, drawn in HTML and CSS: a glossy blue tray of paper * slips for the Inbox, and a white tray with a stack of sheets for Today. * * - Each paper is a box placed by its depth in the pile, --k (0 at the front), through a *   transform. A new one drops in from above (an animation played as it first shows), and those *   already there move back and rise a little, by a transition on that transform. The whole pile *   also rises as it grows (--rise). * - A paper that leaves stays a moment as a ghost, to play its way out: lifted out of the tray
menu.tsx
'use client';import { useEffect, useRef, type KeyboardEvent, type ReactNode } from 'react';import styles from './skeuo-tasks.module.css';/* * A pop-up menu: the list's options, the filter, the actions on the selected tasks. It is always * in the page, hidden until open, so opening it adds nothing to the page. It follows the menu * pattern: arrow keys, Home and End move between the items, Enter or Space picks one, Escape * closes and hands focus back to its button. */export type Item = {  id: string;  label: string;  icon?: ReactNode;  /** A destructive action, in red. */  danger?: boolean;  /** For a choice among several: whether it is the one picked. */  checked?: boolean;};export function Menu({  id,
data.ts
/* * The lists and the tasks the app starts with. A task belongs to one list; moving it changes its * list. The sidebar shows the lists in three groups: the two trays, the collections, and the * spaces. */export type ListId =  'inbox' | 'today' | 'upcoming' | 'someday' | 'log' | 'goals' | 'projects' | 'studio' | 'personal';export type Task = {  id: string;  title: string;  list: ListId;  /** Added in this session: the "Added today" filter keeps these. */  fresh?: boolean;};export type List = {  id: ListId;  name: string;  /** The line under the list's name. */  about: string;};
icons.tsx
/* * The app's icons, on a 20-unit grid. Outlines take the current colour; the coloured list icons * carry their own. All of them are still drawings, in the page from the start. */import type { ReactNode, SVGProps } from 'react';type IconProps = SVGProps<SVGSVGElement>;function Icon({ children, ...props }: IconProps & { children: ReactNode }) {  return (    <svg      viewBox="0 0 20 20"      fill="none"      stroke="currentColor"      strokeWidth="1.6"      strokeLinecap="round"      strokeLinejoin="round"      aria-hidden="true"      focusable="false"      {...props}    >      {children}    </svg>
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, glide * the pointer onto a control, press it and call the component's own handler, as a click 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. * * The pointer is an HTML box around a still drawing: it glides by an animation the compositor * plays, and the press scales a box that keeps a transform at rest, so nothing it does needs a * layout. */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. */  press: (action: () => void) => Promise<void>;
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;
  • The sidebar is a navigation landmark whose list buttons mark the one on show with aria-current. Each tray is a button named with its count (“Today, 4 tasks”).
  • The + opens the new task’s card (aria-expanded), and focus goes to its title field. Enter adds the task and Escape closes the card; both hand focus back to the +.
  • Each row has a real Complete button; in select mode each row is a toggle button (aria-pressed). The menus follow the menu pattern, keys included, and Escape leaves select mode.
  • Adding, moving, ticking off and deleting are announced in a polite live region; the demo stays silent. Under reduced motion papers and rows appear and leave at once, nothing flies, and the demo never plays.