Skip to content
Suluu

DurationPill

A compact duration readout that makes just enough room for precise editing. Its surface stays aligned while the value reorganizes into calm, tactile segments.

Preview

Activate the pencil to edit

Installation

Registry — recommended

Copies the component into your configured shadcn UI directory and installs Motion.

npx shadcn@latest add https://suluu.site/r/duration-pill.json

npm package

For centralized upgrades, install the optional ESM package and configure Tailwind source detection as shown below.

pnpm add suluu motion

Usage

DurationPill keeps a private edit draft. Enter, the check action, or moving focus outside commits one canonical value; Escape closes the pill without publishing the draft. The readout stays passive; activate its pencil button to begin editing.

Registry
import { DurationPill } from "@/components/ui/duration-pill"

export function Estimate() {
  return (
    <DurationPill
      defaultValue={{ hours: 2, minutes: 30, seconds: 0 }}
      onValueChange={(duration) => saveEstimate(duration)}
      step={5}
    />
  )
}
npm
// app/globals.css
@import "suluu/styles.css";
@source "../node_modules/suluu/dist";

// component.tsx
import { DurationPill } from "suluu/duration-pill"

export function Estimate() {
  return (
    <DurationPill
      defaultValue={{ hours: 2, minutes: 30, seconds: 0 }}
    />
  )
}

Controlled value and bounds

Hours are non-negative while minutes and seconds stay between zero and 59. Direct entry remains exact within that range. Bounds apply to the complete duration, out-of-range drafts clamp when committed, and arrow stepping carries naturally between segments. Long hour values widen the pill while space is available. In a constrained layout, the active field scrolls and compact digits truncate visually without changing the committed or accessible value.

Controlled
const [duration, setDuration] = useState({
  hours: 1,
  minutes: 30,
  seconds: 0,
})

<DurationPill
  max={{ hours: 4, minutes: 0, seconds: 0 }}
  min={{ hours: 0, minutes: 15, seconds: 0 }}
  onValueChange={setDuration}
  step={15}
  value={duration}
/>

Seconds

Seconds remain in the value when hidden. Enable their segment only where that precision helps the task.

Seconds
<DurationPill
  defaultValue={{ hours: 0, minutes: 4, seconds: 30 }}
  showSeconds
  step={5}
/>

Formatting and copy

The default formatter omits zero-value units. Use a string formatter, accessible label overrides, and unit abbreviations when the surrounding product needs another notation or vocabulary.

Custom format
<DurationPill
  formatValue={({ hours, minutes }) =>
    `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`
  }
  labels={{
    duration: "Session length",
    edit: "Change session length",
  }}
  unitLabels={{ hours: "Hrs.", minutes: "Mins." }}
/>

In context

A bounded duration estimate that stays quiet until the scheduler needs to change it.

Design review

Set the focused block before adding it to the team calendar.

Props

DurationPill accepts safe native div attributes and forwards its ref to the accessible root group. It intentionally does not render a hidden form field; persist the committed value through onValueChange.

PropTypeDefaultDescription
valueDurationValueControlled duration value.
defaultValueDurationValue0 Hr 0 Min 0 SecInitial uncontrolled duration.
onValueChange(value) => voidRuns once when a distinct draft is committed.
onEditChange(editing) => voidReports internal editor state changes.
minDurationValue0Inclusive minimum duration.
maxDurationValueInclusive maximum duration.
stepnumber1Arrow-key step for minutes and seconds; direct entry remains exact.
showSecondsbooleanfalseShows seconds while preserving them either way.
disabledbooleanfalseDisables every interaction.
readOnlybooleanfalseKeeps the compact value focusable without opening it.
motionIntensity"subtle" | "default" | "expressive""default"Controls the morph spring and content settle.
formatValue(value) => stringReplaces the compact display formatter.
labelsPartial<DurationPillLabels>Accessible widget, action, and field names.
unitLabelsPartial<DurationPillUnitLabels>{ hours: "Hr.", minutes: "Min.", seconds: "Sec." }Visible unit abbreviations.
renderIcon(state: "edit" | "confirm") => ReactNodeReplaces either custom SVG action icon.
classNamestringClass name applied to the root group.

Theming

Override these variables in your light and dark theme scopes. The registry installs the defaults automatically.

--suluu-duration-pill-backgroundCompact readout surface.
--suluu-duration-pill-foregroundPrimary digits and text.
--suluu-duration-pill-mutedUnit labels and edit icon.
--suluu-duration-pill-fieldEditor field tiles.
--suluu-duration-pill-field-activeFocused segment surface.
--suluu-duration-pill-accentConfirmation action surface.
--suluu-duration-pill-accent-foregroundConfirmation action icon.
--suluu-duration-pill-ringKeyboard focus ring.
--suluu-duration-pill-offsetColor behind ring offsets.
--suluu-duration-pill-shadowComplete pill depth.
--suluu-duration-pill-action-shadowConfirmation action depth.

The root exposes display/edit, disabled, and read-only state with data attributes. The shell, display, editor, fields, and action also expose stable slot attributes for local refinement.

Accessibility

The compact value is passive. Its pencil is a native button that opens with pointer activation, Enter, or Space, then moves focus to a real numeric spinbutton. Each segment supports direct entry and arrow stepping. Enter commits, Escape cancels and restores the pencil, and Tab can move through every field and the confirmation action before an outer blur commits.

Disabled pills leave the tab order. Read-only pills remain discoverable as a named, focusable read-only value. Reduced motion removes the shell spring and content displacement while keeping the complete editing flow intact.

↑↓ to navigate↵ to openesc to close