Duration Input
Inputs & Forms

Duration Input

Segmented h/m/s duration entry over one canonical seconds value. Arrows roll digits direction-aware and carry across segments, and typed overflow normalizes on blur so 90 seconds becomes 1m 30s.

Install

npx shadcn@latest add @paragon/duration-input

duration-input.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

const EASE_OUT: [number, number, number, number] = [0.22, 1, 0.36, 1];
const EASE_EXIT: [number, number, number, number] = [0.4, 0, 1, 1];

export type DurationSegment = "h" | "m" | "s";

const SEGMENT_META: Record<
  DurationSegment,
  { label: string; seconds: number }
> = {
  h: { label: "Hours", seconds: 3600 },
  m: { label: "Minutes", seconds: 60 },
  s: { label: "Seconds", seconds: 1 },
};

/** Split total seconds across the configured segments, leading one uncapped. */
function derive(total: number, segments: DurationSegment[]) {
  let rest = Math.max(0, Math.round(total));
  const out: Record<string, number> = {};
  segments.forEach((seg, i) => {
    const size = SEGMENT_META[seg].seconds;
    out[seg] = i === segments.length - 1 ? Math.round(rest / size) : Math.floor(rest / size);
    rest -= out[seg] * size;
  });
  return out;
}

/** One glyph cell: old char rolls out while the new rolls in. */
function DigitCell({
  char,
  direction,
  reduced,
}: {
  char: string;
  direction: 1 | -1;
  reduced: boolean;
}) {
  return (
    <span className="relative inline-flex justify-center overflow-hidden">
      <AnimatePresence mode="popLayout" initial={false}>
        <motion.span
          key={char}
          initial={
            reduced
              ? { opacity: 0 }
              : { y: direction > 0 ? "100%" : "-100%", opacity: 0.25 }
          }
          animate={{ y: "0%", opacity: 1 }}
          exit={
            reduced
              ? { opacity: 0, transition: { duration: 0.1 } }
              : {
                  y: direction > 0 ? "-100%" : "100%",
                  opacity: 0.25,
                  transition: { duration: 0.15, ease: EASE_EXIT },
                }
          }
          transition={{ duration: 0.15, ease: EASE_OUT }}
          className="inline-block"
        >
          {char}
        </motion.span>
      </AnimatePresence>
    </span>
  );
}

export interface DurationInputProps
  extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
  /** Controlled total, in seconds. */
  value?: number;
  /** Initial total in seconds when uncontrolled. */
  defaultValue?: number;
  onValueChange?: (seconds: number) => void;
  /** Which segments to render, coarse to fine. */
  segments?: DurationSegment[];
  /** Form field name; renders a hidden input with the total seconds. */
  name?: string;
  disabled?: boolean;
  /** Disables the digit-roll motion. */
  static?: boolean;
  "aria-label"?: string;
}

/**
 * Segmented h/m/s duration entry over one canonical seconds value. Arrows
 * roll digits direction-aware and carry across segments (59s + 1 rolls the
 * minute); typed overflow normalizes on blur, so 90s becomes 1m 30s.
 */
export function DurationInput({
  value: valueProp,
  defaultValue = 0,
  onValueChange,
  segments = ["h", "m", "s"],
  name,
  disabled = false,
  static: isStatic = false,
  className,
  "aria-label": ariaLabel = "Duration",
  ...props
}: DurationInputProps) {
  const reduced = useReducedMotion() ?? false;
  // Leading segment caps at 99 to keep the layout stable.
  const maxTotal =
    100 * SEGMENT_META[segments[0]].seconds - 1;

  const clampTotal = React.useCallback(
    (v: number) => Math.min(maxTotal, Math.max(0, Math.round(v))),
    [maxTotal],
  );

  const [uncontrolled, setUncontrolled] = React.useState(
    clampTotal(defaultValue),
  );
  const total = clampTotal(valueProp ?? uncontrolled);
  const totalRef = React.useRef(total);
  totalRef.current = total;

  const [draftSeg, setDraftSeg] = React.useState<DurationSegment | null>(null);
  const [draftText, setDraftText] = React.useState("");
  const directionRef = React.useRef<1 | -1>(1);
  const refs = React.useRef<Partial<Record<DurationSegment, HTMLDivElement>>>(
    {},
  );

  const values = derive(total, segments);

  const setTotal = React.useCallback(
    (next: number) => {
      const clamped = clampTotal(next);
      if (clamped === totalRef.current) return;
      directionRef.current = clamped > totalRef.current ? 1 : -1;
      totalRef.current = clamped;
      if (valueProp === undefined) setUncontrolled(clamped);
      onValueChange?.(clamped);
    },
    [clampTotal, valueProp, onValueChange],
  );

  /** Commit the active draft: rebuild the total, letting overflow carry. */
  const commitDraft = React.useCallback(() => {
    if (draftSeg === null) return;
    const parts = { ...values };
    parts[draftSeg] = draftText === "" ? 0 : Number.parseInt(draftText, 10);
    const next = segments.reduce(
      (sum, seg) => sum + (parts[seg] ?? 0) * SEGMENT_META[seg].seconds,
      0,
    );
    setDraftSeg(null);
    setDraftText("");
    setTotal(next);
  }, [draftSeg, draftText, values, segments, setTotal]);

  const focusSegment = (index: number) => {
    const seg = segments[index];
    if (seg) refs.current[seg]?.focus();
  };

  const onSegmentKeyDown = (
    event: React.KeyboardEvent<HTMLDivElement>,
    seg: DurationSegment,
    index: number,
  ) => {
    if (disabled) return;
    if (event.key === "ArrowUp" || event.key === "ArrowDown") {
      event.preventDefault();
      commitDraft();
      const dir = event.key === "ArrowUp" ? 1 : -1;
      setTotal(totalRef.current + dir * SEGMENT_META[seg].seconds);
    } else if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
      event.preventDefault();
      commitDraft();
      focusSegment(index + (event.key === "ArrowRight" ? 1 : -1));
    } else if (event.key === "Enter") {
      event.preventDefault();
      commitDraft();
    } else if (event.key === "Backspace") {
      event.preventDefault();
      if (draftSeg === seg && draftText.length > 0) {
        setDraftText(draftText.slice(0, -1));
      } else {
        setDraftSeg(seg);
        setDraftText("");
      }
    } else if (/^[0-9]$/.test(event.key)) {
      event.preventDefault();
      const text = (draftSeg === seg ? draftText : "") + event.key;
      if (text.length >= 2) {
        // Two digits fill the segment — commit and hop to the next one.
        const parts = { ...values };
        parts[seg] = Number.parseInt(text, 10);
        const next = segments.reduce(
          (sum, s) => sum + (parts[s] ?? 0) * SEGMENT_META[s].seconds,
          0,
        );
        setDraftSeg(null);
        setDraftText("");
        setTotal(next);
        focusSegment(index + 1);
      } else {
        setDraftSeg(seg);
        setDraftText(text);
      }
    }
  };

  return (
    <div
      data-slot="duration-input"
      role="group"
      aria-label={ariaLabel}
      className={cn(
        "inline-flex h-9 items-center gap-1 rounded-lg border border-input bg-transparent px-1.5",
        "transition-[border-color,box-shadow] duration-150 ease-out",
        "focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/25",
        disabled && "pointer-events-none opacity-50",
        className,
      )}
      {...props}
    >
      {segments.map((seg, index) => {
        const drafting = draftSeg === seg;
        const display = drafting
          ? draftText || "·"
          : String(values[seg]).padStart(2, "0");
        const chars = display.split("");
        return (
          <div
            key={seg}
            ref={(node) => {
              if (node) refs.current[seg] = node;
            }}
            role="spinbutton"
            tabIndex={disabled ? -1 : 0}
            aria-label={SEGMENT_META[seg].label}
            aria-valuenow={values[seg]}
            aria-valuemin={0}
            aria-valuetext={`${values[seg]} ${SEGMENT_META[seg].label.toLowerCase()}`}
            onKeyDown={(event) => onSegmentKeyDown(event, seg, index)}
            onBlur={commitDraft}
            className={cn(
              "flex cursor-default items-baseline rounded-md px-1 py-0.5 outline-none select-none",
              "transition-[background-color] duration-150 ease-out",
              "focus:bg-accent focus-visible:ring-2 focus-visible:ring-ring/40",
            )}
          >
            <span
              aria-hidden
              className={cn(
                "flex text-sm font-medium tabular-nums",
                drafting && !draftText && "text-muted-foreground/50",
              )}
            >
              {drafting
                ? display
                : chars.map((char, i) => (
                    <DigitCell
                      key={`${seg}-${i}`}
                      char={char}
                      direction={directionRef.current}
                      reduced={reduced || isStatic}
                    />
                  ))}
            </span>
            <span
              aria-hidden
              className="ml-0.5 text-[11px] text-muted-foreground select-none"
            >
              {seg}
            </span>
          </div>
        );
      })}
      {name && <input type="hidden" name={name} value={total} />}
    </div>
  );
}