# Inline duration

> A read-only duration pill that splits into two fields to edit it, then folds back with the new value.

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.

A React component by Bruno Delorence: https://brunodelorence.com/components/inline-duration. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: Inputs.

## Anatomy

1. **Caption**: Says what the duration is of. The group of fields is labelled by it, and the buttons are named after it: “Save Focus block”.
2. **Field**: A label wrapping one number and its unit. In the pill the fields touch and are pressed to open on that field; apart, each is a fixed-width box.
3. **Shape**: The surface under each piece, white by day and a step lighter than the stage at night. It is what stretches during the morph, from its width in the pill to its width as a field, on a bouncy spring.
4. **Number**: Always a real `<input role="spinbutton">`, inert inside the pill. It takes two digits, and the arrow keys step it.
5. **Unit**: “Hr.” or “Min.”, muted, after the number. Hidden from screen readers, which hear the field’s name instead.
6. **Selection**: The selected digits in the system blue, ready to be typed over, with a 2 px blue ring around the field.
7. **Save button**: The same button as the pencil: in the pill it opens the fields, apart it saves them, and its icon cross-fades with a blur and a turn.
8. **Key hint**: Fades in under the open fields: Enter to save, Esc to cancel. Left out on touch screens.

## 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.

## Installation

Install it with the shadcn CLI, into components/inline-duration of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/inline-duration.json
```

Or copy all the source files below into one folder of your project. Either way it needs React 19.2 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: 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

```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)}
    />
  );
}
```

## States

- **Pill**: Read-only: one rounded shape, “2 Hr. 30 Min.” and a pencil. A screen reader hears “2 hours 30 minutes”.
- **Editing**: The pill split into two fields and a check, with the key hint underneath.
- **Field selected**: Pressing a field selects its digits, so typing replaces them; the field takes a blue ring.
- **At the limits**: Values out of range are clamped: 23 hours and 59 minutes at most, 0 at least.
- **Light**: With `colorScheme="light"`: white shapes on a pale grey dotted stage, whatever the page.
- **Dark**: With `colorScheme="dark"`: the shapes a step lighter than a near-black stage; the blue stays.

## How it works

- 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.

## API reference

- **label** (`string`, default `'Focus block'`): What the duration is of: the caption above it, and the name of its controls.
- **defaultHours** (`number`, default `2`): The starting hours, 0 to 23.
- **defaultMinutes** (`number`, default `30`): The starting minutes, 0 to 59.
- **defaultMode** (`'view' | 'edit'`, default `'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`, default `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.

## Accessibility

- 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.

## Source

### inline-duration/inline-duration.tsx

```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;
 *   the edit button becomes the save button. Only the layout changes, from a data attribute.
 * - The morph is FLIP: before the switch, every moving piece is measured where it is on screen;
 *   after it, where the new layout puts it; then each one plays from the old place to the new one
 *   on a spring. The shapes behind the pieces also stretch from their old width to the new one,
 *   so the pill splits into three and the fields fold back into one. A switch that interrupts
 *   another starts from where each piece is on screen.
 * - The pencil and the check cross-fade with a little blur and a turn.
 * - The fields take digits and the arrow keys (an hour, or five minutes that carry into the
 *   hours); Enter saves and Escape cancels. Changes are announced to screen readers.
 * - While on screen and untouched, a demo cursor edits the duration on its own.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets.
 */

export type Field = 'hours' | 'minutes';
type Duration = Record<Field, number>;
export type Mode = 'view' | 'edit';

/** What the demo does, one step at a time. */
type DemoAct =
  | { type: 'open' }
  | { type: 'commit' }
  | { type: 'select'; field: Field }
  | { type: 'type'; field: Field; text: string };

const FIELDS: Field[] = ['hours', 'minutes'];
const LIMITS: Record<Field, number> = { hours: 23, minutes: 59 };
const UNITS: Record<Field, { short: string; name: string; one: string; many: string }> = {
  hours: { short: 'Hr.', name: 'Hours', one: 'hour', many: 'hours' },
  minutes: { short: 'Min.', name: 'Minutes', one: 'minute', many: 'minutes' },
};

/** The split and the fold: a spring with a touch of bounce. */
const MORPH = spring(0.22, 480);

/** The durations the demo types in, in turn; then back to the first value. */
const DEMO_DURATIONS: Duration[] = [
  { hours: 1, minutes: 45 },
  { hours: 3, minutes: 15 },
];

const clamp = (field: Field, n: number) => Math.min(LIMITS[field], Math.max(0, n || 0));

const toDraft = (duration: Duration): Record<Field, string> => ({
  hours: String(duration.hours),
  minutes: String(duration.minutes),
});

const fromDraft = (draft: Record<Field, string>): Duration => ({
  hours: clamp('hours', parseInt(draft.hours, 10)),
  minutes: clamp('minutes', parseInt(draft.minutes, 10)),
});

/** "2 hours 30 minutes". */
const spoken = (duration: Duration) =>
  FIELDS.map((field) => {
    const n = duration[field];
    return `${n} ${n === 1 ? UNITS[field].one : UNITS[field].many}`;
  }).join(' ');

/** One arrow-key step: an hour, or to the next five minutes, carried into the hours. */
function step(duration: Duration, field: Field, direction: 1 | -1): Duration {
  if (field === 'hours') return { ...duration, hours: clamp('hours', duration.hours + direction) };
  const total = duration.hours * 60 + duration.minutes;
  const next = direction > 0 ? Math.floor(total / 5) * 5 + 5 : Math.ceil(total / 5) * 5 - 5;
  const bounded = Math.min(LIMITS.hours * 60 + 55, Math.max(0, next));
  return { hours: Math.floor(bounded / 60), minutes: bounded % 60 };
}

export function InlineDuration({
  label = 'Focus block',
  defaultHours = 2,
  defaultMinutes = 30,
  onCommit,
  autoplay = true,
  colorScheme,
  defaultMode = 'view',
  defaultField,
  className = '',
}: {
  /** What the duration is of: the caption above it, and its accessible name. */
  label?: string;
  /** The starting hours, 0 to 23. */
  defaultHours?: number;
  /** The starting minutes, 0 to 59. */
  defaultMinutes?: number;
  /** Called with the new duration when an edit is saved. */
  onCommit?: (duration: Duration) => void;
  /** While on screen and untouched, a demo cursor edits the duration on its own. */
  autoplay?: boolean;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  /** Opens as the pill ('view') or with the fields apart ('edit'); 'edit' turns the demo off. */
  defaultMode?: Mode;
  /** With defaultMode 'edit': the field shown selected, ring and highlighted text; turns the demo off. */
  defaultField?: Field;
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const [mode, setMode] = useState<Mode>(defaultMode);
  const [value, setValue] = useState<Duration>(() => ({
    hours: clamp('hours', defaultHours),
    minutes: clamp('minutes', defaultMinutes),
  }));
  const [draft, setDraft] = useState(() => toDraft(value));
  /** The field the demo types in: its text selected first, then a caret. */
  const [demoField, setDemoField] = useState<{ field: Field; selected: boolean } | null>(() =>
    defaultMode === 'edit' && defaultField ? { field: defaultField, selected: true } : null,
  );
  const [focused, setFocused] = useState(false);
  const [message, setMessage] = useState('');
  const labelId = useId();
  const hintId = useId();

  const row = useRef<HTMLDivElement>(null);
  const fields = useRef<Record<Field, HTMLLabelElement | null>>({ hours: null, minutes: null });
  const inputs = useRef<Record<Field, HTMLInputElement | null>>({ hours: null, minutes: null });
  const action = useRef<HTMLButtonElement>(null);
  const cursor = useRef<HTMLSpanElement>(null);
  /** Where each moving piece was on screen just before a switch. */
  const before = useRef<Map<HTMLElement, DOMRect> | null>(null);
  const running = useRef<Animation[]>([]);
  /** Where focus goes once a switch the reader made has rendered. */
  const focusNext = useRef<Field | 'action' | null>(null);

  /** Repeats are made to differ, so screen readers announce them again. */
  const announce = (text: string) => setMessage((now) => (now === text ? `${text}\u00a0` : text));

  /** Switches the layout, after noting where every moving piece is on screen. */
  const switchTo = (next: Mode) => {
    const container = row.current;
    if (container && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
      const movers = container.querySelectorAll<HTMLElement>('[data-morph]');
      before.current = new Map(
        Array.from(movers, (element) => [element, element.getBoundingClientRect()]),
      );
      for (const animation of running.current) animation.cancel();
      running.current = [];
    }
    setMode(next);
  };

  // The FLIP: each piece plays from where it was to where the new layout put it.
  useLayoutEffect(() => {
    const from = before.current;
    const container = row.current;
    before.current = null;
    if (from && container) {
      // Screen pixels to the component's own, should it sit in a scaled container.
      const scale = container.getBoundingClientRect().width / container.offsetWidth || 1;
      const easing = easingOf(MORPH);
      for (const [element, was] of from) {
        const now = element.getBoundingClientRect();
        const start: Keyframe = {
          transform: `translate(${(was.left - now.left) / scale}px, ${(was.top - now.top) / scale}px)`,
        };
        const end: Keyframe = { transform: 'none' };
        // The shapes stretch too, from their old width to the new one.
        if (element.dataset.morph === 'shape') {
          start.width = `${was.width / scale}px`;
          end.width = `${now.width / scale}px`;
        }
        running.current.push(element.animate([start, end], { duration: MORPH.duration, easing }));
      }
    }
    const target = focusNext.current;
    focusNext.current = null;
    if (target === 'action') action.current?.focus();
    else if (target) {
      inputs.current[target]?.focus();
      inputs.current[target]?.select();
    }
  }, [mode]);

  /** Opens the fields; a reader's open puts focus in `focus`. */
  const open = (focus: Field | null) => {
    if (mode === 'edit') return;
    setDraft(toDraft(value));
    focusNext.current = focus;
    switchTo('edit');
  };

  const commit = (byReader: boolean) => {
    if (mode !== 'edit') return;
    const next = fromDraft(draft);
    setValue(next);
    setDraft(toDraft(next));
    setDemoField(null);
    onCommit?.(next);
    if (byReader) {
      focusNext.current = 'action';
      announce(`${label} set to ${spoken(next)}.`);
    }
    switchTo('view');
  };

  const cancel = () => {
    if (mode !== 'edit') return;
    setDraft(toDraft(value));
    setDemoField(null);
    focusNext.current = 'action';
    announce(`Edit cancelled. ${label} is still ${spoken(value)}.`);
    switchTo('view');
  };

  const onFieldKeyDown = (event: KeyboardEvent<HTMLInputElement>, field: Field) => {
    if (event.key === 'Enter') {
      event.preventDefault();
      commit(true);
    } else if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
      event.preventDefault();
      setDraft(toDraft(step(fromDraft(draft), field, event.key === 'ArrowUp' ? 1 : -1)));
    }
  };

  // The demo: the cursor presses the pencil, types a new duration in each field, and saves.
  const readState = useEffectEvent(() => ({ mode, value }));
  const demoAct = useEffectEvent((act: DemoAct) => {
    if (act.type === 'open') open(null);
    else if (act.type === 'commit') commit(false);
    else if (act.type === 'select') setDemoField({ field: act.field, selected: true });
    else {
      setDraft((now) => ({ ...now, [act.field]: act.text }));
      setDemoField({ field: act.field, selected: false });
    }
  });
  const demo = autoplay && defaultMode === 'view' && !defaultField && playing && !focused;
  useEffect(() => {
    const stage = root.current;
    const arrow = cursor.current;
    if (!demo || !stage || !arrow) return;
    const first = readState().value;
    const turns = [...DEMO_DURATIONS, first];
    const stop = playDemo(stage, arrow, async ({ wait, moveTo, press, leave }) => {
      for (let turn = 0; ; turn += 1) {
        await wait(turn ? 1700 : 900);
        const button = action.current;
        if (!button) return;
        // Left open by a reader: save what is there first.
        if (readState().mode === 'edit') {
          await moveTo(button);
          await press(button, () => demoAct({ type: 'commit' }));
          await wait(1100);
        }
        await moveTo(button);
        await press(button, () => demoAct({ type: 'open' }));
        await wait(420);
        const next = turns[turn % turns.length];
        for (const field of FIELDS) {
          const box = fields.current[field];
          if (!box) return;
          await moveTo(box, { x: -30, y: 6 });
          await press(box, () => demoAct({ type: 'select', field }));
          await wait(340);
          const text = String(next[field]);
          for (let length = 1; length <= text.length; length += 1) {
            demoAct({ type: 'type', field, text: text.slice(0, length) });
            await wait(150);
          }
          await wait(260);
        }
        await moveTo(button);
        await press(button, () => demoAct({ type: 'commit' }));
        await wait(300);
        await leave();
      }
    });
    return () => {
      stop();
      setDemoField(null);
    };
  }, [demo, root]);

  useEffect(() => () => running.current.forEach((animation) => animation.cancel()), []);

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      data-mode={mode}
      style={{ '--spring': MORPH.easing, '--spring-ms': `${MORPH.duration}ms` } as CSSProperties}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
      onFocus={() => setFocused(true)}
      onBlur={(event) => {
        if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false);
      }}
      onKeyDown={(event) => {
        if (event.key === 'Escape' && mode === 'edit') {
          event.preventDefault();
          cancel();
        }
      }}
    >
      <div className={styles.body}>
        <span id={labelId} className={styles.caption} data-part="caption">
          {label}
        </span>
        <div
          ref={row}
          className={styles.duration}
          role="group"
          aria-labelledby={labelId}
          data-part="duration"
        >
          {mode === 'view' && <span className={styles.hidden}>{spoken(value)}</span>}
          {FIELDS.map((field) => {
            const text = mode === 'edit' ? draft[field] : String(value[field]);
            const demoState =
              demoField?.field === field ? (demoField.selected ? 'selected' : 'typing') : undefined;
            return (
              <label
                key={field}
                ref={(element) => {
                  fields.current[field] = element;
                }}
                className={`${styles.piece} ${styles.field}`}
                data-part="field"
                data-field={field}
                data-demo={demoState}
                style={{ '--chars': text.length } as CSSProperties}
                onClick={mode === 'view' ? () => open(field) : undefined}
              >
                <span className={styles.surface} data-morph="shape" data-part="shape" />
                <input
                  ref={(element) => {
                    inputs.current[field] = element;
                  }}
                  className={styles.number}
                  data-part="number"
                  data-morph=""
                  value={text}
                  inert={mode === 'view'}
                  role="spinbutton"
                  aria-label={UNITS[field].name}
                  aria-valuemin={0}
                  aria-valuemax={LIMITS[field]}
                  aria-valuenow={parseInt(text, 10) || 0}
                  aria-describedby={hintId}
                  inputMode="numeric"
                  enterKeyHint="done"
                  autoComplete="off"
                  spellCheck={false}
                  onFocus={(event) => event.currentTarget.select()}
                  onChange={(event) => {
                    const digits = event.target.value.replace(/\D/g, '').slice(0, 2);
                    setDraft((now) => ({ ...now, [field]: digits }));
                  }}
                  onKeyDown={(event) => onFieldKeyDown(event, field)}
                />
                <span className={styles.unit} data-part="unit" data-morph="" aria-hidden="true">
                  {UNITS[field].short}
                </span>
                <span className={styles.selection} data-part="selection" aria-hidden="true" />
                <span className={styles.caret} data-part="caret" aria-hidden="true" />
              </label>
            );
          })}
          <button
            ref={action}
            type="button"
            className={`${styles.piece} ${styles.action}`}
            data-part="action"
            aria-label={mode === 'view' ? `Edit ${label}` : `Save ${label}`}
            onClick={() => (mode === 'view' ? open('hours') : commit(true))}
          >
            <span className={styles.surface} data-morph="shape" data-part="shape" />
            <span className={styles.icons} data-part="icon" data-morph="">
              <svg className={styles.pencil} viewBox="0 0 20 20" aria-hidden="true">
                <g transform="rotate(45 10 10)">
                  <path d="M7.7 4.1a2.3 2.3 0 0 1 4.6 0v1.1H7.7z" />
                  <path d="M7.7 6.6h4.6v7.6L10 18.6l-2.3-4.4z" />
                </g>
              </svg>
              <svg className={styles.check} viewBox="0 0 20 20" aria-hidden="true">
                <path d="m4.6 10.4 3.5 3.5 7.3-7.6" />
              </svg>
            </span>
          </button>
        </div>
        <p
          id={hintId}
          className={styles.hint}
          data-part="hint"
          aria-hidden={mode === 'view' || undefined}
        >
          <kbd>Enter</kbd> to save, <kbd>Esc</kbd> to cancel
        </p>
      </div>
      <DemoCursor ref={cursor} />
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}
```

### inline-duration/inline-duration.module.css

```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
     selected text (--selection). */
  --accent: #0a84ff;
  --selection: rgb(10 132 255 / 0.22);
  /* The demo's cursor (demo-cursor.module.css): the system's arrow on both schemes, and the soft
     ring where it presses, dark by day and light at night. */
  --pointer: #1d1d1f;
  --pointer-edge: #fff;
  --ripple: rgb(0 0 0 / 0.14);
  --height: 56px;
  --radius: 18px;
  --field: 136px;
  --gap: 10px;
  --pad: 18px;
  position: relative;
  container-type: inline-size;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background:
    radial-gradient(var(--dots) 1px, transparent 1px) 0 0 / 14px 14px,
    var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink);
  font:
    400 18px/1.2 -apple-system,
    BlinkMacSystemFont,
    'SF Pro Text',
    Inter,
    'Segoe UI',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#e4e4e7, #121214);
    --surface-2: light-dark(#fff, #242427);
    --hover: light-dark(#f6f6f7, #2c2c2f);
    --ink: light-dark(#1d1d1f, #ececef);
    --ink-muted: light-dark(#8e8e93, #8e8e95);
    --label: light-dark(#636366, #a1a1a6);
    --key: light-dark(rgb(0 0 0 / 0.05), rgb(255 255 255 / 0.08));
    --key-ink: light-dark(#3a3a3c, #d1d1d6);
    --line: light-dark(rgb(0 0 0 / 0.06), rgb(255 255 255 / 0.06));
    --dots: light-dark(rgb(0 0 0 / 0.055), rgb(255 255 255 / 0.05));
    --selection: light-dark(rgb(10 132 255 / 0.22), rgb(10 132 255 / 0.4));
    --ripple: light-dark(rgb(0 0 0 / 0.14), rgb(255 255 255 / 0.16));
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

.body {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 14px;
  min-height: 420px;
  padding: 48px 16px;
}

.caption {
  font-size: 13px;
  font-weight: 500;
  letter-spacing: 0.01em;
  color: var(--label);
}

/* The pieces: the two fields and the button, each over a shape of its own (white by day). */
.duration {
  position: relative;
  display: flex;
  height: var(--height);
}
.piece {
  position: relative;
  display: flex;
  flex: none;
  align-items: center;
  box-sizing: border-box;
  height: 100%;
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
}
.surface {
  position: absolute;
  z-index: 0;
  inset: 0;
  border-radius: var(--radius);
  background: var(--surface-2);
  transition:
    border-radius var(--spring-ms) var(--spring),
    box-shadow 0.2s ease,
    background-color 0.2s ease;
}
/* Over every shape, whichever piece it belongs to. */
.number,
.unit,
.icons {
  position: relative;
  z-index: 1;
}

.number {
  box-sizing: border-box;
  min-width: 0;
  height: 100%;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 0;
  background: none;
  color: inherit;
  font: inherit;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  outline: none;
  appearance: none;
}
.number::selection {
  background: var(--selection);
}
.unit {
  flex: none;
  font-weight: 500;
  color: var(--ink-muted);
  user-select: none;
}

/* The pill: the pieces touch, their shapes overlap by a pixel so no seam shows, and only the
   outer corners are round. */
.root[data-mode='view'] .field {
  cursor: pointer;
}
.root[data-mode='view'] [data-field='hours'] {
  padding-left: 21px;
}
.root[data-mode='view'] [data-field='minutes'] {
  padding-left: 11px;
}
.root[data-mode='view'] .number {
  width: calc(max(1, var(--chars)) * 1ch + 2px);
}
.root[data-mode='view'] .unit {
  margin-left: 4px;
}
.root[data-mode='view'] .field .surface {
  right: -1px;
}
.root[data-mode='view'] [data-field='hours'] .surface {
  border-top-right-radius: 0;
  border-bottom-right-radius: 0;
}
.root[data-mode='view'] [data-field='minutes'] .surface {
  border-radius: 0;
}
.root[data-mode='view'] .action {
  width: 54px;
}
.root[data-mode='view'] .action .surface {
  border-top-left-radius: 0;
  border-bottom-left-radius: 0;
}

/* The fields: apart, each number on the left of its field and its unit on the right. */
.root[data-mode='edit'] .duration {
  gap: var(--gap);
}
.root[data-mode='edit'] .field {
  width: var(--field);
  padding: 0 var(--pad);
}
.root[data-mode='edit'] .number {
  flex: 1;
  width: 0;
}
.root[data-mode='edit'] .action {
  width: var(--height);
}
.root[data-mode='edit'] .field:focus-within .surface,
.root[data-mode='edit'] .field[data-demo] .surface {
  box-shadow: 0 0 0 2px var(--accent);
}

/* The demo's typing: the text selected first, then a caret after it. */
.selection,
.caret {
  position: absolute;
  z-index: 1;
  top: 50%;
  left: var(--pad);
  height: 1.3em;
  margin-top: -0.65em;
  border-radius: 3px;
  pointer-events: none;
  visibility: hidden;
}
.selection {
  width: calc(var(--chars) * 1ch + 2px);
  background: var(--selection);
}
.caret {
  left: calc(var(--pad) + var(--chars) * 1ch + 1px);
  width: 2px;
  border-radius: 1px;
  background: var(--accent);
}
.field[data-demo='selected'] .selection,
.field[data-demo='typing'] .caret {
  visibility: visible;
}

/* The button: a pencil in the pill, a check beside the fields. */
.action {
  justify-content: center;
  cursor: pointer;
}
.action:focus-visible {
  outline: none;
}
.icons {
  display: grid;
  place-items: center;
  width: 36px;
  height: 36px;
  border-radius: 12px;
}
.icons svg {
  grid-area: 1 / 1;
  width: 20px;
  height: 20px;
  transition:
    opacity 0.28s ease,
    filter 0.28s ease,
    color 0.2s ease,
    transform var(--spring-ms) var(--spring);
}
.pencil {
  fill: currentColor;
  color: var(--ink-muted);
}
.check {
  fill: none;
  stroke: currentColor;
  stroke-width: 2.4;
  stroke-linecap: round;
  stroke-linejoin: round;
  color: var(--ink);
}
.root[data-mode='edit'] .pencil,
.root[data-mode='view'] .check {
  opacity: 0;
  filter: blur(4px);
  transform: scale(0.5) rotate(-35deg);
}
@media (hover: hover) {
  .root[data-mode='view'] .action:hover .pencil {
    color: var(--ink);
  }
  .root[data-mode='edit'] .action:hover .surface {
    background: var(--hover);
  }
}
.root[data-mode='view'] .action:is(:active, [data-pressed]) .pencil,
.root[data-mode='edit'] .action:is(:active, [data-pressed]) .check {
  transform: scale(0.84);
}
.root[data-mode='view'] .action:focus-visible .icons {
  box-shadow: 0 0 0 2px var(--accent);
}
.root[data-mode='edit'] .action:focus-visible .surface {
  box-shadow: 0 0 0 2px var(--accent);
}

/* The keys, under the fields while they are open (on screens with a pointer). */
.hint {
  margin: 0;
  font-size: 12px;
  color: var(--label);
  opacity: 0;
  transition: opacity 0.25s ease;
}
.root[data-mode='edit'] .hint {
  opacity: 1;
}
.hint kbd {
  display: inline-block;
  padding: 2px 6px;
  border-radius: 6px;
  background: var(--key);
  font: inherit;
  font-weight: 500;
  color: var(--key-ink);
}
@media (hover: none) {
  .hint {
    display: none;
  }
}

/* For screen readers only. */
.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@container (max-width: 400px) {
  .body {
    --height: 52px;
    --field: 116px;
    --gap: 8px;
    --pad: 15px;
    min-height: 340px;
    font-size: 17px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .surface,
  .icons svg,
  .hint {
    transition: none;
  }
}
```

### inline-duration/spring.ts

```tsx
/*
 * 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;
  const omega = (2 * Math.PI * 1000) / response;
  const position = (t: number) => {
    if (damping >= 1) return 1 - Math.exp(-omega * t) * (1 + omega * t);
    const wobble = omega * Math.sqrt(1 - damping * damping);
    const decay = Math.exp(-damping * omega * t);
    return 1 - decay * (Math.cos(wobble * t) + ((damping * omega) / wobble) * Math.sin(wobble * t));
  };
  // Settled: from then on, within 0.1% of the target.
  let settled = 0;
  for (let t = 0; t < 3; t += 0.002) if (Math.abs(1 - position(t)) > 0.001) settled = t;
  const points = Array.from({ length: SAMPLES + 1 }, (_, i) =>
    i === SAMPLES ? 1 : Math.round(position((i / SAMPLES) * settled) * 1e4) / 1e4,
  );
  return { easing: `linear(${points.join(', ')})`, duration: Math.round(settled * 1000) };
}

/** The spring's easing for `element.animate()`, or an ease-out where `linear()` is unknown. */
export function easingOf({ easing }: Spring) {
  return CSS.supports('transition-timing-function', easing)
    ? easing
    : 'cubic-bezier(0.2, 0.8, 0.2, 1)';
}
```

### inline-duration/demo-cursor.tsx

```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>
  );
}

export type DemoSteps = {
  /** Resolves after `ms`; never, once the demo has stopped. */
  wait: (ms: number) => Promise<void>;
  /** Glides the cursor to the middle of `target`, or `offset` from it (in the stage's px). */
  moveTo: (target: Element, offset?: { x: number; y: number }) => Promise<void>;
  /** Presses `target`: the cursor dips, the target shows itself pressed, `action` runs. */
  press: (target: Element, action: () => void) => Promise<void>;
  /** Moves the cursor off and fades it out. */
  leave: () => Promise<void>;
};

type Point = { x: number; y: number };

/**
 * Plays `script` on `stage` with the cursor. The function it returns stops the demo at once: the
 * step in progress never resolves, and the cursor fades away.
 */
export function playDemo(
  stage: HTMLElement,
  cursor: HTMLElement,
  script: (steps: DemoSteps) => Promise<void>,
) {
  let alive = true;
  let timer = 0;
  let pressed: Element | null = null;
  let at: Point = { x: 0, y: 0 };

  const wait = (ms: number) =>
    new Promise<void>((resolve) => {
      timer = window.setTimeout(() => {
        if (alive) resolve();
      }, ms);
    });

  /** A point on the screen, in the stage's own px (it may sit in a scaled container). */
  const toStage = (x: number, y: number): Point => {
    const box = stage.getBoundingClientRect();
    const scale = box.width / stage.offsetWidth || 1;
    return { x: (x - box.left) / scale, y: (y - box.top) / scale };
  };

  const place = (point: Point, ms: number) => {
    at = point;
    cursor.style.setProperty('--move', `${ms}ms`);
    cursor.style.setProperty('--x', `${point.x}px`);
    cursor.style.setProperty('--y', `${point.y}px`);
  };

  const moveTo = async (target: Element, offset: Point = { x: 0, y: 0 }) => {
    const box = target.getBoundingClientRect();
    const middle = toStage(box.left + box.width / 2, box.top + box.height / 2);
    const to = { x: middle.x + offset.x, y: middle.y + offset.y };
    if (cursor.dataset.state === 'hidden') {
      // Comes in from below and to the right of its first target, inside the stage.
      place(
        {
          x: Math.min(to.x + 70, stage.offsetWidth - 24),
          y: Math.min(to.y + 80, stage.offsetHeight - 24),
        },
        0,
      );
      // Applies that jump before the glide starts.
      cursor.getBoundingClientRect();
      cursor.dataset.state = 'shown';
    }
    // Longer trips take a little longer.
    const ms = Math.round(Math.min(900, Math.max(420, Math.hypot(to.x - at.x, to.y - at.y) * 2.2)));
    place(to, ms);
    await wait(ms + 40);
  };

  const press = async (target: Element, action: () => void) => {
    cursor.dataset.state = 'pressed';
    target.setAttribute('data-pressed', '');
    pressed = target;
    cursor.querySelector(`.${styles.ripple}`)?.animate(
      [
        { transform: 'scale(0.2)', opacity: 0.5 },
        { transform: 'scale(1)', opacity: 0 },
      ],
      { duration: 520, easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)' },
    );
    await wait(130);
    action();
    cursor.dataset.state = 'shown';
    target.removeAttribute('data-pressed');
    pressed = null;
    await wait(120);
  };

  const leave = async () => {
    place({ x: at.x + 36, y: at.y + 44 }, 500);
    cursor.dataset.state = 'hidden';
    await wait(500);
  };

  void script({ wait, moveTo, press, leave });

  return () => {
    alive = false;
    window.clearTimeout(timer);
    cursor.dataset.state = 'hidden';
    pressed?.removeAttribute('data-pressed');
  };
}
```

### inline-duration/demo-cursor.module.css

```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 {
  display: block;
  transform: translateY(var(--y));
  transition: transform var(--move) cubic-bezier(0.3, 0.55, 0.25, 1);
}
.arrow {
  display: block;
  width: 22px;
  height: 22px;
  /* The tip, at (4, 2.5) of the drawing, on the point. */
  margin: -2.75px 0 0 -4.4px;
  overflow: visible;
  transform-origin: 4.4px 2.75px;
  transition: transform 0.12s ease;
}
.arrow path {
  fill: var(--pointer);
  stroke: var(--pointer-edge);
  stroke-width: 1.3;
  stroke-linejoin: round;
}
.cursor[data-state='pressed'] .arrow {
  transform: scale(0.86);
}
/* A soft ring where it presses. */
.ripple {
  position: absolute;
  top: -15px;
  left: -15px;
  width: 30px;
  height: 30px;
  border-radius: 50%;
  background: var(--ripple);
  opacity: 0;
}
```

### inline-duration/use-autoplay.ts

```tsx
'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;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, playing: visible && !reduced && !taken };
}
```