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.jsonnpm package
For centralized upgrades, install the optional ESM package and configure Tailwind source detection as shown below.
pnpm add suluu motionUsage
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.
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}
/>
)
}// 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.
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.
<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.
<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.
| Prop | Type | Default | Description |
|---|---|---|---|
| value | DurationValue | — | Controlled duration value. |
| defaultValue | DurationValue | 0 Hr 0 Min 0 Sec | Initial uncontrolled duration. |
| onValueChange | (value) => void | — | Runs once when a distinct draft is committed. |
| onEditChange | (editing) => void | — | Reports internal editor state changes. |
| min | DurationValue | 0 | Inclusive minimum duration. |
| max | DurationValue | — | Inclusive maximum duration. |
| step | number | 1 | Arrow-key step for minutes and seconds; direct entry remains exact. |
| showSeconds | boolean | false | Shows seconds while preserving them either way. |
| disabled | boolean | false | Disables every interaction. |
| readOnly | boolean | false | Keeps the compact value focusable without opening it. |
| motionIntensity | "subtle" | "default" | "expressive" | "default" | Controls the morph spring and content settle. |
| formatValue | (value) => string | — | Replaces the compact display formatter. |
| labels | Partial<DurationPillLabels> | — | Accessible widget, action, and field names. |
| unitLabels | Partial<DurationPillUnitLabels> | { hours: "Hr.", minutes: "Min.", seconds: "Sec." } | Visible unit abbreviations. |
| renderIcon | (state: "edit" | "confirm") => ReactNode | — | Replaces either custom SVG action icon. |
| className | string | — | Class 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.