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.
npx shadcn@latest add @paragon/duration-input"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>
);
}