# Name Every Timing Value

**SHOULD** · **ID:** `animations-named-timing-constants` · **Category:** animations
**Source:** Custom
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/animations-named-timing-constants

> SHOULD: Hoist every delay, duration and easing into one named block and drive a multi-stage sequence with a single integer stage, not scattered magic numbers and boolean flags (`isCardVisible` / `isHeadingVisible` / `areRowsVisible`) — otherwise retuning the tempo means hunting five call sites and the heading silently starts arriving after the rows it introduces.

Hoist every delay, duration and easing into one named block and drive the sequence with a single integer

From Josh Puckett's Interface Craft. A multi-stage reveal wired as delay: 0.3 here, delay: 0.9 there, stagger: 0.2 inline in the JSX, sequenced by isCardVisible / isHeadingVisible / areRowsVisible, has no readable choreography: the order exists only in the author's head, and retuning it means hunting magic numbers across five call sites. The failure is concrete — change the tempo, miss one of those sites, and the heading now arrives after the rows it was supposed to introduce, a bug that survives code review precisely because no number is named. The fix is two moves: hoist the values into one block (const TIMING = { cardAppear: 300, heading: 900, rows: 1500 }) and collapse the flags into one integer stage, so the JSX reads stage >= 2 ? ... : ... and the sequence is legible top to bottom. ADJACENT TO BUT DISTINCT FROM animations-emil-motion-cohesion: cohesion is about one shared motion vocabulary ACROSS the product, so that a dropdown here and a toast there feel like the same hand made them. This is about one sequence, inside one component, being readable and tunable at all. You can be perfectly cohesive and still have an unmaintainable timeline; and a single well-named TIMING block does not, by itself, make the product coherent.

## Rule snippet

```tsx
const TIMING = { cardAppear: 300, heading: 900, rows: 1500 } as const;
// then: stage >= 2 ? ... : ...
```

## Bad — do not do this

`animations-named-timing-constants-bad`

```tsx
import { useEffect, useState } from 'react';

const ROWS = ['Latency', 'Errors', 'Throughput'];

/**
 * Bad: the sequence is encoded in three independent booleans and a scatter of magic
 * numbers — 300 / 900 / 1500 in the timeouts, 0.26s / 0.2s inline in the JSX. Nobody
 * can read the choreography out of this file, and retuning it means hunting.
 *
 * The tempo control shows the actual cost. Someone re-tuned the sequence and missed
 * one call site (the heading timeout below). At 0.5× the heading now arrives AFTER
 * the rows it is supposed to introduce — a bug that is invisible in code review
 * precisely because the numbers are not named.
 */
export function NamedTimingConstantsBad() {
  const [run, setRun] = useState(0);
  const [tempo, setTempo] = useState(1);

  const [isCardVisible, setIsCardVisible] = useState(false);
  const [isHeadingVisible, setIsHeadingVisible] = useState(false);
  const [areRowsVisible, setAreRowsVisible] = useState(false);

  useEffect(() => {
    setIsCardVisible(false);
    setIsHeadingVisible(false);
    setAreRowsVisible(false);

    const t1 = setTimeout(() => setIsCardVisible(true), 300 * tempo);
    const t2 = setTimeout(() => setIsHeadingVisible(true), 900); // the retune missed this one
    const t3 = setTimeout(() => setAreRowsVisible(true), 1500 * tempo);

    return () => {
      clearTimeout(t1);
      clearTimeout(t2);
      clearTimeout(t3);
    };
  }, [run, tempo]);

  return (
    <div className="w-full space-y-4">
      <div className="flex flex-wrap items-center gap-3">
        <button
          onClick={() => setRun((r) => r + 1)}
          className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm transition-transform duration-150 ease-out active:scale-[0.97] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
        >
          Replay sequence
        </button>
        <label className="flex items-center gap-2 text-xs text-muted-foreground">
          Tempo
          <select
            value={tempo}
            onChange={(e) => setTempo(Number(e.target.value))}
            className="rounded-md border border-border bg-card px-2 py-1 text-xs text-card-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
          >
            <option value={1}>1×</option>
            <option value={0.5}>0.5×</option>
          </select>
        </label>
        <span className="text-xs text-muted-foreground">
          flags: card={String(isCardVisible)} heading={String(isHeadingVisible)} rows={String(areRowsVisible)}
        </span>
      </div>

      <div className="h-44 rounded-lg border border-border bg-muted/40 p-4">
        <div
          className="rounded-lg border border-border bg-card p-4"
          style={{
            transition: 'transform 0.26s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.26s cubic-bezier(0.23, 1, 0.32, 1)',
            transform: isCardVisible ? 'translateY(0)' : 'translateY(12px)',
            opacity: isCardVisible ? 1 : 0,
          }}
        >
          <p
            className="text-sm font-medium text-card-foreground"
            style={{
              transition: 'opacity 0.24s ease-out',
              opacity: isHeadingVisible ? 1 : 0,
            }}
          >
            Service health
          </p>
          <ul className="mt-3 space-y-1.5">
            {ROWS.map((row, i) => (
              <li
                key={row}
                className="rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground"
                style={{
                  transition: 'opacity 0.24s ease-out',
                  transitionDelay: `${i * 0.2}s`,
                  opacity: areRowsVisible ? 1 : 0,
                }}
              >
                {row}
              </li>
            ))}
          </ul>
        </div>
      </div>

      <p className="text-xs text-destructive">
        Three booleans and five magic numbers spread across the file. Switch tempo to 0.5× and the rows appear before
        the heading that introduces them — the retune updated two of the three call sites, and nothing in the code
        made that omission visible.
      </p>
    </div>
  );
}
```

## Good — do this

`animations-named-timing-constants-good`

```tsx
import { useEffect, useState } from 'react';
import { ReducedMotionSwitch } from '@/components/demo-kit/ReducedMotionSwitch';
import { useSimulatedReducedMotion } from '@/hooks/useSimulatedReducedMotion';

const ROWS = ['Latency', 'Errors', 'Throughput'];

/**
 * Every value that affects timing or appearance is a named constant, in one block,
 * trivially adjustable. Read this and you have read the choreography.
 */
const TIMING = {
  cardAppear: 300,
  heading: 900,
  rows: 1500,
  duration: 260,
  rowStagger: 120,
  ease: 'cubic-bezier(0.23, 1, 0.32, 1)',
} as const;

/** Stage 0 = nothing, 1 = card, 2 = heading, 3 = rows. One integer, not three booleans. */
const CUES = [TIMING.cardAppear, TIMING.heading, TIMING.rows];

/**
 * Good: the identical sequence, driven by a single `stage` integer and a single
 * TIMING block. The JSX reads `stage >= 2 ? ... : ...`, so the order is legible at a
 * glance and impossible to desync — retuning the whole sequence means editing one
 * object, and the tempo multiplier lands on every cue because there is only one
 * place for it to land.
 *
 * Related to but distinct from motion cohesion: that rule is about one motion
 * vocabulary across the product. This one is about one sequence being readable.
 */
export function NamedTimingConstantsGood() {
  const [run, setRun] = useState(0);
  const [tempo, setTempo] = useState(1);
  const [stage, setStage] = useState(0);
  const reduced = useSimulatedReducedMotion();

  useEffect(() => {
    setStage(0);
    const timers = CUES.map((ms, i) => setTimeout(() => setStage(i + 1), ms * tempo));
    return () => timers.forEach(clearTimeout);
  }, [run, tempo]);

  const cardStyle = reduced
    ? { transition: `opacity 150ms linear`, transform: 'none', opacity: stage >= 1 ? 1 : 0 }
    : {
        transition: `transform ${TIMING.duration}ms ${TIMING.ease}, opacity ${TIMING.duration}ms ${TIMING.ease}`,
        transform: stage >= 1 ? 'translateY(0)' : 'translateY(12px)',
        opacity: stage >= 1 ? 1 : 0,
      };

  return (
    <div className="w-full space-y-4">
      <div className="flex flex-wrap items-center gap-3">
        <button
          onClick={() => setRun((r) => r + 1)}
          className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm transition-transform duration-150 ease-out active:scale-[0.97] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
        >
          Replay sequence
        </button>
        <label className="flex items-center gap-2 text-xs text-muted-foreground">
          Tempo
          <select
            value={tempo}
            onChange={(e) => setTempo(Number(e.target.value))}
            className="rounded-md border border-border bg-card px-2 py-1 text-xs text-card-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
          >
            <option value={1}>1×</option>
            <option value={0.5}>0.5×</option>
          </select>
        </label>
        <ReducedMotionSwitch />
        <span className="text-xs tabular-nums text-muted-foreground">
          stage: <strong className="text-foreground">{stage}</strong>/3
        </span>
      </div>

      <div className="h-44 rounded-lg border border-border bg-muted/40 p-4">
        <div className="rounded-lg border border-border bg-card p-4" style={cardStyle}>
          <p
            className="text-sm font-medium text-card-foreground"
            style={{
              transition: `opacity ${TIMING.duration}ms ${TIMING.ease}`,
              opacity: stage >= 2 ? 1 : 0,
            }}
          >
            Service health
          </p>
          <ul className="mt-3 space-y-1.5">
            {ROWS.map((row, i) => (
              <li
                key={row}
                className="rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground"
                style={{
                  transition: `opacity ${TIMING.duration}ms ${TIMING.ease}`,
                  transitionDelay: `${i * TIMING.rowStagger}ms`,
                  opacity: stage >= 3 ? 1 : 0,
                }}
              >
                {row}
              </li>
            ))}
          </ul>
        </div>
      </div>

      <p className="text-xs text-success">
        One <code>TIMING</code> block, one <code>stage</code> integer. The tempo multiplier hits every cue because
        there is only one list of cues, so 0.5× keeps the order intact — and the sequence can be retuned without
        reading the JSX at all.
      </p>
    </div>
  );
}
```

## References

- [Interface Craft — Josh Puckett](https://interfacecraft.dev/)
- [joshpuckett.me](https://joshpuckett.me/)
- [MDN — Using CSS custom properties](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascading_variables/Using_CSS_custom_properties)
