Inline duration
A read-only duration pill that splits into two fields to edit it, then folds back with the new value.
inline-duration/inline-duration.tsxThe 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 duration that is read far more often than it is changed: a focus block, a meeting length, a timer preset in a settings row.
- Editing in place, where opening a dialog or a separate form would cost more than the change itself.
- Hours and minutes within a day: each field takes up to two digits and stops at
23hours and59minutes. - Keyboard-first settings: the arrow keys step an hour or five minutes (carrying into the hours),
Entersaves andEsccancels.
When not to use
- Durations longer than a day, or with seconds. Use a field per unit that goes that far, or a free-text input that parses “1d 4h”.
- A time of day (9:30) rather than a length of time. Use a time picker.
- A form with a Save button of its own: a second, inline save confuses which one keeps the value. Use two plain number fields.
- Picking from a few fixed lengths (15, 30, 60 minutes). A segmented control or a menu shows them at once.
- Rows of many durations in a table: the split needs room for two 136 px fields and a button on every row.
You can add this component with the shadcn CLI or by hand:
Into components/inline-duration of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/inline-duration.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
'use client';import { InlineDuration } from './inline-duration';export function FocusSettings() { return ( <InlineDuration label="Focus block" defaultHours={2} defaultMinutes={30} autoplay={false} onCommit={({ hours, minutes }) => console.log(hours, minutes)} /> );}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 duration shown as a pill, “2 Hr. 30 Min.”, with a pencil. Press it and the pill splits: three shapes stretch and slide apart into an hours field, a minutes field and a check, while the pencil blurs into the check. Save, and the fields fold back into one pill with the new value.
- One set of elements serves both states: the numbers are always inputs (inert inside the pill), and the pencil button becomes the save button. A data attribute switches the layout.
- The morph is FLIP: every piece is measured on screen before the switch and again in the new layout, then played from the old place to the new one with the Web Animations API.
- The shapes behind the pieces also stretch from their old width to the new one, so the pill splits into three and folds back. In the pill they overlap by a pixel, so no seam shows.
- The motion follows a spring with a touch of bounce, sampled once into a CSS linear() easing that the transitions and the animations share. A switch that interrupts another starts from where each piece is.
- The pencil and the check cross-fade with a blur and a turn.
- Every colour is a light-dark() token on the root, so it follows the page’s color-scheme, or the colorScheme prop; at night the shapes become a step-lighter grey on a near-black dotted stage, and the selected text follows too.
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | 'Focus block' | What the duration is of: the caption above it, and the name of its controls. |
defaultHours | number | 2 | The starting hours, 0 to 23. |
defaultMinutes | number | 30 | The starting minutes, 0 to 59. |
defaultMode | 'view' | 'edit' | 'view' | Opens as the pill or with the fields apart; 'edit' turns the demo off. |
defaultField | 'hours' | 'minutes' | — | With defaultMode 'edit', the field shown selected, ring and highlighted text. Setting it turns the demo off. |
onCommit | (duration: { hours: number; minutes: number }) => void | — | Called with the new duration when an edit is saved. |
autoplay | boolean | true | While on screen and untouched, a demo cursor edits the duration. 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. |
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, useEffectEvent, useId, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent,} from 'react';import { DemoCursor, playDemo } from './demo-cursor';import { easingOf, spring } from './spring';import { useAutoplay } from './use-autoplay';import styles from './inline-duration.module.css';/* * Inline duration: a read-only pill ("2 Hr. 30 Min.") that splits into two fields to edit it, and * folds back into a pill with the new value. * * - One set of elements serves both states. The numbers are always <input>s, inert in the pill;/* Inline duration (inline-duration.tsx): a light stage with the duration in its middle, or at night a near-black one with the shapes a step lighter. The root's data-mode (view or edit) sets the layout; the component plays the move between the two. 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). */.root { /* The dotted stage, and the shapes the pieces sit on; the check's shape under the pointer. */ --surface: #e4e4e7; --surface-2: #fff; --hover: #f6f6f7; /* The numbers and the check; the units and the pencil; the caption and the hint. */ --ink: #1d1d1f; --ink-muted: #8e8e93; --label: #636366; /* The keys in the hint, and their names. */ --key: rgb(0 0 0 / 0.05); --key-ink: #3a3a3c; /* The stage's edge, and its dots. */ --line: rgb(0 0 0 / 0.06); --dots: rgb(0 0 0 / 0.055); /* The accent, the system's blue on both schemes: the focus rings and the caret, and the/* * 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 client';import type { Ref } from 'react';import styles from './demo-cursor.module.css';/* * The demo's cursor. While a demo plays on its own, an arrow glides to the controls and presses * them, so the demo reads like someone using the component. Each glide is a CSS transition, one * per axis with a different easing, which bends the path into a gentle arc: nothing runs between * moves. */export function DemoCursor({ ref }: { ref: Ref<HTMLSpanElement> }) { return ( <span ref={ref} className={styles.cursor} data-state="hidden" aria-hidden="true"> <span className={styles.vertical}> <span className={styles.ripple} /> <svg className={styles.arrow} viewBox="0 0 20 20"> <path d="M4 2.5 15 13h-4.6l2.4 5.2-2.4 1-2.4-5.2L4 17.2z" /> </svg> </span> </span> );}/* The demo's cursor (demo-cursor.tsx): an arrow whose tip sits at (--x, --y) in its stage. The outer box carries the horizontal move, the inner one the vertical move, eased differently. Its colours are the stage's tokens (inline-duration.module.css). */.cursor { --x: 0px; --y: 0px; --move: 0ms; position: absolute; top: 0; left: 0; z-index: 10; pointer-events: none; opacity: 0; transform: translateX(var(--x)); transition: opacity 0.25s ease, transform var(--move) cubic-bezier(0.45, 0, 0.25, 1);}.cursor[data-state='shown'],.cursor[data-state='pressed'] { opacity: 1;}.vertical {'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 pencil is a labelled button, “Edit Focus block”, that becomes “Save Focus block”. Focus moves into the hours field on open, and back to the button on save or cancel.
- The fields are spin buttons: they take digits and the arrow keys (an hour, or five minutes that carry into the hours). Enter saves and Escape cancels; the keys are shown under the fields and given as their description.
- Saves and cancels are announced politely, for example “Focus block set to 1 hour 45 minutes.”
- Under reduced motion the layout switches at once and the demo does not play.