Skip to content
38

Duration Input

Segmented duration field over days, hours, minutes and seconds. Digits auto-advance, arrows step, and the value stays a plain number of seconds.

Example

Installation

npx shadcn@latest add https://hirael.com/r/duration-input.json

Usage

import {
  DurationInput,
  DurationInputContainer,
  DurationInputSegment,
  DurationInputSegments,
  DurationInputClear,
} from "@/components/duration-input"

Paths assume the default @/ alias from your components.json. The example above shows the parts composed.

API

<DurationInput />

+ native element props
PropTypeDefault
value

Total duration in seconds, or `null` while empty.

number | nullnone
defaultValuenumber | nullnull
onValueChange((value: number | null) => void)none
units

Which segments to show. Order is normalised to largest first.

DurationUnit[]none
max

Upper bound in seconds. Also sets how wide the largest segment types.

numbernone
disabledbooleannone
readOnlybooleannone
childrenReact.ReactNodenone

<DurationInputContainer />

+ native element props

No props of its own; forwards everything to the underlying element.

<DurationInputSegment />

+ native element props
PropTypeDefault
unit*DurationUnitnone
suffix

Short label after the digits. Pass `null` to drop it.

React.ReactNodenone

<DurationInputSegments />

+ native element props

No props of its own; forwards everything to the underlying element.

<DurationInputClear />

+ native element props

No props of its own; forwards everything to the underlying element.

Component source

'use client';

import * as React from 'react';
import { X } from 'lucide-react';

import { cn } from '@/lib/utils';
import { InputGroup } from '@/registry/hirael/bases/radix/ui/input-group';
import { useControllableState } from '@/registry/hirael/hooks/use-controllable-state';

export type DurationUnit = 'd' | 'h' | 'm' | 's';

const UNIT_SECONDS: Record<DurationUnit, number> = { d: 86400, h: 3600, m: 60, s: 1 };

const UNIT_ORDER: readonly DurationUnit[] = ['d', 'h', 'm', 's'];

const UNIT_NAMES: Record<DurationUnit, string> = {
  d: 'days',
  h: 'hours',
  m: 'minutes',
  s: 'seconds',
};

const sortUnits = (units: readonly DurationUnit[]): DurationUnit[] => UNIT_ORDER.filter((u) => units.includes(u));

// Snaps to the smallest unit first, so 3599s with minutes smallest reads `01 h 00 m`, not `00 h 60 m`.
const splitDuration = (total: number, units: DurationUnit[]): Record<DurationUnit, number> => {
  const parts: Record<DurationUnit, number> = { d: 0, h: 0, m: 0, s: 0 };
  const smallest = UNIT_SECONDS[units[units.length - 1]];
  let rest = Math.round(Math.max(0, total) / smallest) * smallest;
  for (const unit of units) {
    parts[unit] = Math.floor(rest / UNIT_SECONDS[unit]);
    rest -= parts[unit] * UNIT_SECONDS[unit];
  }

  return parts;
};

const joinDuration = (parts: Record<DurationUnit, number>, units: DurationUnit[]): number =>
  units.reduce((total, unit) => total + parts[unit] * UNIT_SECONDS[unit], 0);

interface DurationDraft {
  unit: DurationUnit;
  text: string;
}

interface DurationInputContextValue {
  seconds: number | null;
  units: DurationUnit[];
  parts: Record<DurationUnit, number>;
  maxFor: (unit: DurationUnit) => number;
  widthFor: (unit: DurationUnit) => number;
  setUnit: (unit: DurationUnit, next: number) => void;
  clear: () => void;
  draft: DurationDraft | null;
  setDraft: (next: DurationDraft | null) => void;
  disabled?: boolean;
  readOnly?: boolean;
}

const DurationInputContext = React.createContext<DurationInputContextValue | null>(null);

const useDurationInput = () => {
  const ctx = React.useContext(DurationInputContext);
  if (!ctx) {
    throw new Error('DurationInput compound parts must be used inside <DurationInput>');
  }

  return ctx;
};

export interface DurationInputProps extends Omit<React.ComponentProps<'div'>, 'defaultValue' | 'onChange'> {
  /** Total duration in seconds, or `null` while empty. */
  value?: number | null;
  defaultValue?: number | null;
  onValueChange?: (value: number | null) => void;
  /** Which segments to show. Order is normalised to largest first. */
  units?: DurationUnit[];
  /** Upper bound in seconds. Also sets how wide the largest segment types. */
  max?: number;
  disabled?: boolean;
  readOnly?: boolean;
  children?: React.ReactNode;
}

const DurationInput = ({
  value: valueProp,
  defaultValue = null,
  onValueChange,
  units: unitsProp,
  max,
  disabled,
  readOnly,
  className,
  children,
  ...props
}: DurationInputProps) => {
  const [seconds, setControlledSeconds] = useControllableState<number | null>({
    prop: valueProp,
    defaultProp: defaultValue,
    onChange: onValueChange,
  });

  const unitsKey = sortUnits(unitsProp ?? ['h', 'm']).join('') || 'hm';
  const units = React.useMemo(() => unitsKey.split('') as DurationUnit[], [unitsKey]);

  const [draft, setDraft] = React.useState<DurationDraft | null>(null);

  const setSeconds = React.useCallback(
    (next: number | null) => {
      const clamped = next === null ? null : Math.max(0, max === undefined ? next : Math.min(max, next));
      setControlledSeconds(clamped);
    },
    [setControlledSeconds, max],
  );

  const parts = React.useMemo(() => splitDuration(seconds ?? 0, units), [seconds, units]);

  const maxFor = React.useCallback(
    (unit: DurationUnit) => {
      const index = units.indexOf(unit);
      if (index > 0) return UNIT_SECONDS[units[index - 1]] / UNIT_SECONDS[unit] - 1;

      return max === undefined ? Infinity : Math.floor(max / UNIT_SECONDS[unit]);
    },
    [units, max],
  );

  const widthFor = React.useCallback(
    (unit: DurationUnit) => {
      const limit = maxFor(unit);

      return Number.isFinite(limit) ? Math.max(2, String(limit).length) : 3;
    },
    [maxFor],
  );

  const setUnit = React.useCallback(
    (unit: DurationUnit, next: number) => {
      if (disabled || readOnly) return;
      const bounded = Math.max(0, Math.min(maxFor(unit), next));
      setSeconds(joinDuration({ ...parts, [unit]: bounded }, units));
    },
    [disabled, readOnly, maxFor, parts, units, setSeconds],
  );

  const clear = React.useCallback(() => {
    setDraft(null);
    setSeconds(null);
  }, [setSeconds]);

  const ctx = React.useMemo<DurationInputContextValue>(
    () => ({
      seconds,
      units,
      parts,
      maxFor,
      widthFor,
      setUnit,
      clear,
      draft,
      setDraft,
      disabled,
      readOnly,
    }),
    [seconds, units, parts, maxFor, widthFor, setUnit, clear, draft, disabled, readOnly],
  );

  return (
    <DurationInputContext.Provider value={ctx}>
      <div role="group" data-slot="duration-input" className={cn('flex flex-col gap-2', className)} {...props}>
        {children ?? (
          <DurationInputContainer>
            <DurationInputSegments />
          </DurationInputContainer>
        )}
      </div>
    </DurationInputContext.Provider>
  );
};

const DurationInputContainer = ({ className, children, onMouseDown, ...props }: React.ComponentProps<'div'>) => {
  const ctx = useDurationInput();

  return (
    <InputGroup
      data-slot="duration-input-container"
      data-disabled={ctx.disabled || undefined}
      data-readonly={ctx.readOnly || undefined}
      data-empty={ctx.seconds === null || undefined}
      onMouseDown={(e) => {
        onMouseDown?.(e);
        if (e.defaultPrevented) return;
        if (e.target !== e.currentTarget) return;
        e.preventDefault();
        const first = e.currentTarget.querySelector<HTMLInputElement>('[data-slot="duration-input-segment"]');
        first?.focus();
      }}
      className={cn(
        'gap-0.5 px-2 text-sm focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50',
        (ctx.disabled || ctx.readOnly) && 'cursor-not-allowed opacity-60',
        className,
      )}
      {...props}
    >
      {children}
    </InputGroup>
  );
};

export interface DurationInputSegmentProps extends Omit<
  React.ComponentProps<'input'>,
  'value' | 'defaultValue' | 'onChange' | 'type'
> {
  unit: DurationUnit;
  /** Short label after the digits. Pass `null` to drop it. */
  suffix?: React.ReactNode;
}

const DurationInputSegment = ({
  unit,
  suffix,
  className,
  onKeyDown,
  onFocus,
  onBlur,
  ...props
}: DurationInputSegmentProps) => {
  const ctx = useDurationInput();
  if (!ctx.units.includes(unit)) return null;

  const width = ctx.widthFor(unit);
  const limit = ctx.maxFor(unit);
  const draftText = ctx.draft?.unit === unit ? ctx.draft.text : null;
  const editing = draftText !== null;
  const display = editing ? draftText : ctx.seconds === null ? '' : String(ctx.parts[unit]).padStart(2, '0');

  const focusSibling = (el: HTMLElement, delta: number) => {
    const next = ctx.units[ctx.units.indexOf(unit) + delta];
    if (!next) return;
    el.closest('[data-slot="duration-input"]')?.querySelector<HTMLInputElement>(`[data-unit="${next}"]`)?.focus();
  };

  const handleChange = (el: HTMLInputElement) => {
    const text = el.value
      .replace(/\D/g, '')
      .slice(-width)
      .replace(/^0+(?=\d)/, '');
    ctx.setDraft({ unit, text });
    ctx.setUnit(unit, Number(text));
    if (text !== '' && (text.length >= width || Number(text) * 10 > limit)) {
      ctx.setDraft(null);
      focusSibling(el, 1);
    }
  };

  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    onKeyDown?.(e);
    if (e.defaultPrevented || ctx.disabled || ctx.readOnly) return;
    const current = ctx.seconds === null ? 0 : ctx.parts[unit];

    if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
      e.preventDefault();
      ctx.setDraft(null);
      ctx.setUnit(unit, current + (e.key === 'ArrowUp' ? 1 : -1) * (e.shiftKey ? 10 : 1));
    } else if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
      e.preventDefault();
      const rtl = getComputedStyle(e.currentTarget).direction === 'rtl';
      focusSibling(e.currentTarget, (e.key === 'ArrowRight') !== rtl ? 1 : -1);
    } else if (e.key === 'Backspace' && (!editing || draftText === '')) {
      e.preventDefault();
      ctx.setDraft({ unit, text: '' });
      ctx.setUnit(unit, 0);
    }
  };

  return (
    <span data-slot="duration-input-part" className="inline-flex items-baseline">
      <input
        inputMode="numeric"
        autoComplete="off"
        spellCheck={false}
        role="spinbutton"
        aria-label={UNIT_NAMES[unit]}
        aria-valuenow={ctx.seconds === null ? undefined : ctx.parts[unit]}
        aria-valuemin={0}
        aria-valuemax={Number.isFinite(limit) ? limit : undefined}
        data-slot="duration-input-segment"
        data-unit={unit}
        value={display}
        placeholder={'-'.repeat(width)}
        disabled={ctx.disabled}
        readOnly={ctx.readOnly}
        onChange={(e) => handleChange(e.currentTarget)}
        onKeyDown={handleKeyDown}
        onFocus={(e) => {
          onFocus?.(e);
          e.currentTarget.select();
        }}
        onBlur={(e) => {
          onBlur?.(e);
          ctx.setDraft(null);
        }}
        style={{ width: `${width}ch` }}
        className={cn(
          'rounded-sm bg-transparent text-center tabular-nums outline-none',
          'placeholder:text-muted-foreground focus:bg-accent focus:text-accent-foreground disabled:cursor-not-allowed',
          className,
        )}
        {...props}
      />
      {suffix !== null && (
        <span data-slot="duration-input-suffix" className="me-1 text-xs text-muted-foreground">
          {suffix ?? unit}
        </span>
      )}
    </span>
  );
};

const DurationInputSegments = ({ className, ...props }: React.ComponentProps<'span'>) => {
  const ctx = useDurationInput();

  return (
    <span
      data-slot="duration-input-segments"
      className={cn('inline-flex items-baseline gap-0.5', className)}
      {...props}
    >
      {ctx.units.map((unit) => (
        <DurationInputSegment key={unit} unit={unit} />
      ))}
    </span>
  );
};

const DurationInputClear = ({ className, children, onClick, ...props }: React.ComponentProps<'button'>) => {
  const ctx = useDurationInput();
  if (ctx.seconds === null || ctx.disabled || ctx.readOnly) return null;

  return (
    <button
      type="button"
      tabIndex={-1}
      aria-label="Clear duration"
      data-slot="duration-input-clear"
      onClick={(e) => {
        onClick?.(e);
        if (e.defaultPrevented) return;
        ctx.clear();
      }}
      className={cn(
        'ms-auto inline-flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground',
        className,
      )}
      {...props}
    >
      {children ?? <X className="size-3.5" />}
    </button>
  );
};

export {
  DurationInput,
  DurationInputContainer,
  DurationInputSegment,
  DurationInputSegments,
  DurationInputClear,
  splitDuration,
  joinDuration,
};

Dependencies

shadcn registry

input-groupuse-controllable-state

npm

lucide-react

Shipped