# Scale Duration With Travel Distance

**SHOULD** · **ID:** `animations-lottie-distance-duration` · **Category:** animations
**Source:** [LottieFiles](https://github.com/lottiefiles/motion-design-skill)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/animations-lottie-distance-duration

> SHOULD: Derive duration from travel distance, sublinearly — 100px = base, 200px = 1.3x, 400px = 1.6x — then clamp to a ~140ms floor and ~400ms ceiling. One `--duration-md` cannot serve an 8px tooltip nudge and a full-height sheet. This is a third axis, not a duplicate: `animations-proportional-values` scales transform AMPLITUDE, `animations-emil-duration-budget` assigns duration by element TYPE, and distance is what reconciles them (it is why drawers are the one type allowed past 300ms — a drawer is not special, it is far).

Derive duration from how far the element actually travels, sublinearly — one token cannot serve 8px and 200px

Spend a single --duration-md: 200ms on both an 8px tooltip nudge and a full-height bottom sheet and the same number is wrong in both directions at once: the tooltip crawls the last few pixels of a journey the eye finished instantly, and the sheet covers 200px faster than the eye can track, so it reads as a teleport. Derive it instead: factor = 1 + 0.3 * log2(px / 100), giving 100px = 1×, 200px = 1.3×, 400px = 1.6× — sublinear, because doubling the distance must not double the duration or long journeys feel like wading — then clamp to a floor (~140ms) and a ceiling (~400ms). On a 250ms base that puts the sheet at ~325ms and the tooltip at 140ms. THIS IS A THIRD AXIS, not a duplicate: animations-proportional-values (Rauno) scales the AMPLITUDE of a transform to the trigger SIZE — how far to move — and animations-emil-duration-budget assigns duration by element TYPE (press 100–160ms, tooltip 125–200ms, dropdown 150–250ms, drawer 200–500ms). Distance is what reconciles them, and it is precisely why that budget lists drawers as the one type allowed past the 300ms ceiling: a drawer is not special, it is simply far.

## Rule snippet

```tsx
const factor = 1 + 0.3 * Math.log2(px / 100); // 100px=1x, 200px=1.3x, 400px=1.6x
const duration = Math.min(400, Math.max(140, 250 * factor));
```

## Bad — do not do this

`animations-lottie-distance-duration-bad`

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

const EASE = 'cubic-bezier(0.23, 1, 0.32, 1)';

/** One "medium" duration token, spent on everything that moves. */
const DURATION_MD_MS = 200;

const TOOLTIP_TRAVEL_PX = 8;
const SHEET_TRAVEL_PX = 200;

/**
 * Bad: the tooltip travels 8px and the bottom sheet travels 200px, and both are
 * given the same `--duration-md: 200ms`. The tooltip crawls the last few pixels
 * of a journey the eye finished instantly; the sheet covers a quarter of the panel
 * in the same time and reads as a teleport.
 */
export function LottieDistanceDurationBad() {
  const [tooltipOpen, setTooltipOpen] = useState(false);
  const [sheetOpen, setSheetOpen] = useState(false);

  return (
    <div className="w-full space-y-4">
      <div className="flex flex-wrap items-center gap-2">
        <button
          onClick={() => setTooltipOpen((v) => !v)}
          className="px-3 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"
        >
          Toggle tooltip
        </button>
        <button
          onClick={() => setSheetOpen((v) => !v)}
          className="px-3 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"
        >
          Toggle sheet
        </button>
      </div>

      <div className="relative h-56 overflow-hidden rounded-lg border border-border bg-muted/40">
        <div className="flex h-full items-start justify-center p-6">
          <div className="relative">
            <span
              className="absolute -top-8 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-md border border-border bg-card px-2 py-1 text-[11px] text-card-foreground shadow-sm"
              style={{
                transition: `transform ${DURATION_MD_MS}ms ${EASE}, opacity ${DURATION_MD_MS}ms ${EASE}`,
                transform: tooltipOpen
                  ? 'translate(-50%, 0)'
                  : `translate(-50%, ${TOOLTIP_TRAVEL_PX}px)`,
                opacity: tooltipOpen ? 1 : 0,
              }}
            >
              {TOOLTIP_TRAVEL_PX}px in {DURATION_MD_MS}ms
            </span>
            <span className="rounded-md border border-border bg-card px-3 py-1.5 text-xs text-card-foreground">
              Anchor
            </span>
          </div>
        </div>

        <div
          className="absolute inset-x-0 bottom-0 rounded-t-xl border-t border-border bg-card p-4"
          style={{
            height: SHEET_TRAVEL_PX,
            transition: `transform ${DURATION_MD_MS}ms ${EASE}`,
            transform: sheetOpen ? 'translateY(0)' : 'translateY(100%)',
          }}
        >
          <p className="text-sm font-medium text-card-foreground">Bottom sheet</p>
          <p className="mt-1 text-xs text-muted-foreground">
            {SHEET_TRAVEL_PX}px in {DURATION_MD_MS}ms — 25× the distance, same duration.
          </p>
        </div>
      </div>

      <p className="text-xs text-destructive">
        One duration token for a 8px nudge and a 200px surface. The tooltip feels sluggish for what it is, and the
        sheet arrives faster than the eye can track it — the same number is wrong in both directions at once.
      </p>
    </div>
  );
}
```

## Good — do this

`animations-lottie-distance-duration-good`

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

const EASE = 'cubic-bezier(0.23, 1, 0.32, 1)';

const BASE_PX = 100;
const BASE_MS = 250; // a 100px journey
const MIN_MS = 140; // interaction-feedback floor: below this, motion stops reading as motion
const MAX_MS = 400; // ceiling: nothing on this surface may take longer

/**
 * Duration grows with travel distance, but sublinearly: 100px = 1x, 200px = 1.3x,
 * 400px = 1.6x. Doubling the distance must NOT double the duration, or long
 * journeys feel like wading. Clamp both ends so the formula cannot produce a
 * duration that is imperceptible or interminable.
 */
function durationFor(distancePx: number): number {
  const factor = 1 + 0.3 * Math.log2(Math.max(distancePx, 1) / BASE_PX);
  return Math.round(Math.min(MAX_MS, Math.max(MIN_MS, BASE_MS * factor)));
}

const TOOLTIP_TRAVEL_PX = 8;
const SHEET_TRAVEL_PX = 200;

const TOOLTIP_MS = durationFor(TOOLTIP_TRAVEL_PX);
const SHEET_MS = durationFor(SHEET_TRAVEL_PX);

/**
 * Good: same tooltip, same sheet, same easing — the only change is that each
 * duration is derived from how far the element actually travels. The tooltip snaps
 * across its 8px; the sheet takes long enough for the eye to follow 200px of
 * surface, which is exactly why drawers are the exception in a per-element budget.
 */
export function LottieDistanceDurationGood() {
  const [tooltipOpen, setTooltipOpen] = useState(false);
  const [sheetOpen, setSheetOpen] = useState(false);
  const reduced = useSimulatedReducedMotion();

  return (
    <div className="w-full space-y-4">
      <div className="flex flex-wrap items-center gap-2">
        <button
          onClick={() => setTooltipOpen((v) => !v)}
          className="px-3 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"
        >
          Toggle tooltip
        </button>
        <button
          onClick={() => setSheetOpen((v) => !v)}
          className="px-3 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"
        >
          Toggle sheet
        </button>
        <ReducedMotionSwitch />
      </div>

      <div className="relative h-56 overflow-hidden rounded-lg border border-border bg-muted/40">
        <div className="flex h-full items-start justify-center p-6">
          <div className="relative">
            <span
              className="absolute -top-8 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-md border border-border bg-card px-2 py-1 text-[11px] text-card-foreground shadow-sm"
              style={
                reduced
                  ? {
                      transition: 'opacity 150ms linear',
                      transform: 'translate(-50%, 0)',
                      opacity: tooltipOpen ? 1 : 0,
                    }
                  : {
                      transition: `transform ${TOOLTIP_MS}ms ${EASE}, opacity ${TOOLTIP_MS}ms ${EASE}`,
                      transform: tooltipOpen
                        ? 'translate(-50%, 0)'
                        : `translate(-50%, ${TOOLTIP_TRAVEL_PX}px)`,
                      opacity: tooltipOpen ? 1 : 0,
                    }
              }
            >
              {TOOLTIP_TRAVEL_PX}px in {TOOLTIP_MS}ms
            </span>
            <span className="rounded-md border border-border bg-card px-3 py-1.5 text-xs text-card-foreground">
              Anchor
            </span>
          </div>
        </div>

        <div
          className="absolute inset-x-0 bottom-0 rounded-t-xl border-t border-border bg-card p-4"
          style={
            reduced
              ? {
                  height: SHEET_TRAVEL_PX,
                  transition: 'opacity 150ms linear',
                  transform: 'translateY(0)',
                  opacity: sheetOpen ? 1 : 0,
                  pointerEvents: sheetOpen ? undefined : 'none',
                }
              : {
                  height: SHEET_TRAVEL_PX,
                  transition: `transform ${SHEET_MS}ms ${EASE}`,
                  transform: sheetOpen ? 'translateY(0)' : 'translateY(100%)',
                }
          }
        >
          <p className="text-sm font-medium text-card-foreground">Bottom sheet</p>
          <p className="mt-1 text-xs text-muted-foreground">
            {SHEET_TRAVEL_PX}px in {SHEET_MS}ms — 25× the distance, only {(SHEET_MS / TOOLTIP_MS).toFixed(1)}× the
            duration.
          </p>
        </div>
      </div>

      <p className="text-xs text-success">
        <code>durationFor(px)</code> scales sublinearly: 100px = 1×, 200px = 1.3×, 400px = 1.6×, clamped to 140–400ms.
        The tooltip lands in {TOOLTIP_MS}ms, the sheet in {SHEET_MS}ms — one rule, two right answers.
      </p>
    </div>
  );
}
```

## References

- [LottieFiles — motion-design skill](https://github.com/lottiefiles/motion-design-skill/blob/main/skills/motion-design/SKILL.md)
- [NN/g — Executing UX Animations: Duration and Motion](https://www.nngroup.com/articles/animation-duration/)
- [MDN — Using CSS custom properties](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascading_variables/Using_CSS_custom_properties)
