Contracts table
NewA dark contracts table with a notice-date timeline per row, approval circles, risk bars and a floating action bar for the selection.
contracts-table/contracts-table.tsxContracts
128 active · Playbook SaaS vendor v3
| Contract | Timeline, October to March | Approvals | |||
|---|---|---|---|---|---|
| Kestrel Cloud | Oct 31 | 24 | |||
| Halden Works | Nov 1 | 78 | |||
| Mosaic Data | Dec 1 | 41 | |||
| Quill & Co | Dec 30 | 55 | |||
| Arbor Labs | Dec 31 | 33 | |||
| Fernway Health | Jan 1 | 12 | |||
| Tidewell Studio | Jan 20 | 29 | |||
| Brightline Freight | Feb 2 | 18 | |||
| Copperleaf Media | Feb 15 | 62 |
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 legal or procurement queue where the date that matters is a deadline to act (the last day to give notice) before something happens on its own (the renewal).
- Rows that each carry a short sign-off chain (Legal, Finance, Security, Owner) and need to show where it stops at a glance, with the detail one hover away.
- Bulk work on a filtered list: tick a few contracts, then remind their owners, push their notice dates or open their reviews together from one bar.
- A few dozen to a few hundred items that people re-sort between two questions, what is due first and what is riskiest, and need to see the order change.
When not to use
- One contract’s detail. Use a review page with its clauses, redlines and the full approval history.
- Dates spread over years or many events per row. Use a Gantt or project timeline; the track here covers six months and three marks.
- Thousands of rows with free filtering and column picking. Use a data grid with virtual scrolling and server-side sort.
- A plain list with no deadlines, approvals or risk. Use a simple table or a list; the timeline and circles would be noise.
- Phones as the main screen. Under 500 px the table scrolls sideways; give mobile a card list grouped by notice date instead.
You can add this component with the shadcn CLI or by hand:
Into components/contracts-table of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/contracts-table.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.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
import { ContractsTable } from './contracts-table';export function Renewals() { return ( <ContractsTable defaultTab="renewing" defaultSelected={[]} autoplay={false} onOpenReview={(contracts) => console.log(contracts.map((c) => c.id))} /> );}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.
The contracts a team keeps, as one card. Tabs filter by status, and each row shows its next six months on a small timeline: a bar up to the last day to give notice, a hatched window to the renewal, a tick on that day, and a dashed line for today. Notice dates three weeks off turn amber. Select rows and an action bar slides up to remind owners, extend notice or open a review; sort by notice date or risk and the rows glide into place.
- A real table with fixed column widths. The timeline is 124 px for the six months from October 1, so a date is a position: the grey bar runs to the notice date, the hatched window from there to the renewal (a 45° repeating gradient) and a 2 px tick marks the renewal. Today’s dashed line is one repeating gradient per row, at the same x.
- Extend notice moves the selected contracts 30 days later. The window and the tick slide by a transform and the grey bar grows to meet them, over 600 ms; a notice date that is no longer close loses its amber.
- Sorting is FLIP: before the change each row’s place is noted, after it each row is moved back where it was by a transform and played to zero by the Web Animations API in 420 ms. A new tab fades its rows in instead, 28 ms apart.
- The tabs’ pill is one element that slides to the active tab and takes its width; arrow keys move between tabs.
- The action bar is always there, translated below the frame and transparent; any selection slides it up in 380 ms, with the count rising into place when it changes. The bottom of the list fades to the frame’s colour under it, and the list scrolls under a sticky header.
- Approvals are four 18 px circles: approved (green check), in review (eight spokes turning in steps), changes requested (red cross) and waiting (dashed ring). Hovering one shows its step and state; focusing the cell lists all four.
- While on screen and untouched, a demo pointer extends a notice, selects a row, reads an approval, sorts by risk, selects all and visits another tab, then starts again. Any key, press or wheel hands it to the reader.
- Every colour is a light-dark() token on the root, so the card follows the page’s color-scheme or the colorScheme prop. Under 500 px the table scrolls sideways inside its frame, the subtitle wraps and the action bar spans the frame.
| Prop | Type | Default | Description |
|---|---|---|---|
contracts | Contract[] | CONTRACTS | The rows: name, type, value in $k, status, notice and renewal days from October 1, four approvals, risk 0–100 and owner. The tab counts come from them. |
playbook | string | 'Playbook SaaS vendor v3' | The playbook the reviews follow, under the title. |
defaultTab | 'all' | 'renewing' | 'review' | 'signed' | 'renewing' | The tab open at first. |
defaultSelected | string[] | ['halden'] | Ids of the contracts selected at first. |
onNewReview | () => void | — | Called by the New review button. |
onOpenReview | (contracts: Contract[]) => void | — | Called by Open review, with the selected contracts. |
onRemind | (contracts: Contract[]) => void | — | Called by Remind owner, with the selected contracts. |
autoplay | boolean | true | While on screen and untouched, a demo pointer goes through the table: extend, select, sort, select all, change tabs. |
colorScheme | 'light' | 'dark' | 'system' | — | Light or dark colours, or the reader’s; without it, the page’s color-scheme. |
defaultState | { sort?: 'notice' | 'risk'; extended?: string[]; tip?: string; hover?: string; reminded?: boolean } | — | Opens on a moment: a sort, contracts already extended by 30 days, a row’s approvals tooltip open, a hovered row, or Reminder sent. Giving it keeps the demo off. |
className | string | — | 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.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useEffectEvent, useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent,} from 'react';import { CONTRACTS, SPAN, STEPS, TICKS, TODAY, dayLabel, type Approval, type Contract,/* Contracts table (contracts-table.tsx): a card on a quiet stage, near-black at night and white by day. The card is 582px wide at most; under that it takes the stage's width and the table scrolls sideways inside its frame, so the page never does. Colours are the tokens below, light-dark(light, dark) where the two differ: 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). Motion is transforms and opacity: the action bar slides, rows glide when re-sorted (the Web Animations API) and fade in on a new tab, the notice window slides along its track. The tab pill also eases its width. Under reduced motion, all of it simply changes. */.root { /* The stage, the card on it, the table's frame and its header. */ --stage: #f1f1f2; --card: #fff; --card-edge: rgb(0 0 0 / 0.08); --frame: #fbfbfb; --head: #fff; --line: #ececee; --row-hover: #f5f5f7; --row-selected: #f1f0ff; /* Words: strong, muted, faint. */ --ink: #141416;// Copyright (c) 2026 Bruno Delorence. MIT License./* * The contracts the table shows: invented companies, plausible terms. Dates are days counted from * the timeline's first day (October 1), so the drawing needs no calendar maths; `dayLabel` turns * them back into "Oct 31". Nine contracts are up for renewal and four are in review, written out * by hand; the 115 signed ones are made from two word lists by a fixed recipe, so every render * draws the same table. *//** Where one sign-off stands. */export type Approval = 'done' | 'active' | 'rejected' | 'waiting';export type Status = 'renewing' | 'review' | 'signed';export type Contract = { id: string; name: string; /** The kind of paper: MSA, DPA, Order form… */ type: string; /** Yearly value, in thousands of dollars. */ value: number; status: Status; /** The last day to give notice, in days from October 1. */// Copyright (c) 2026 Bruno Delorence. MIT License.'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`. */// Copyright (c) 2026 Bruno Delorence. MIT License.'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(- A real table: column headers, each contract’s name as its row header, aria-sort on the sorted column. Notice and Risk in the header are buttons that sort; so is the “Sorted by” label.
- The tabs are a tablist with arrow keys and roving focus, controlling the table as its tabpanel.
- Each row and the header have real checkboxes (the header one is mixed when some rows are chosen), named after their contract. Escape in the table clears the selection.
- The action bar is a toolbar, inert while nothing is selected. Sorting, tab changes, reminders and extensions are announced in a polite live region.
- Each timeline is an image named with its dates (“Notice by Oct 31, renews Dec 30”); the approvals cell is focusable, named with all four states, and shows them in a tooltip on focus.
- Focus rings show for keyboard use. Under reduced motion nothing glides, fades or spins, and the demo never plays.