Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

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.tsx
Live · interactive
Focus block
2 hours 30 minutes

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 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 23 hours and 59 minutes.
  • Keyboard-first settings: the arrow keys step an hour or five minutes (carrying into the hours), Enter saves and Esc cancels.

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:

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

focus-settings.tsx
'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.
PropTypeDefaultDescription
labelstring'Focus block'What the duration is of: the caption above it, and the name of its controls.
defaultHoursnumber2The starting hours, 0 to 23.
defaultMinutesnumber30The 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.
autoplaybooleantrueWhile 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.
classNamestring—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.

inline-duration.tsx
// 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.module.css
/* 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
spring.ts
/* * 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;
demo-cursor.tsx
'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>  );}
demo-cursor.module.css
/* 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-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 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.