- Accordion
- Agent Thinking
- Alert
- Alert Dialog
- Announcement
- Aspect Ratio
- Attachment
- Avatar
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Checkbox
- Checkbox Card
- Code Block
- Collapsible
- Color Picker
- Combobox
- Command
- Composer
- Composer Loader
- Context Menu
- Data Grid
- Data Table
- Date Picker
- Dialog
- Direction
- Drawer
- Dropdown Menu
- Empty
- Field
- File Upload
- Hover Card
- Input
- Input Group
- Input Otp
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Meter
- Native Select
- Navigation Menu
- Number Field
- Pagination
- Period Switcher
- Popover
- Progress
- Questionnaire
- Radio Card
- Radio Group
- Resizable
- Reveal
- Scroll Area
- Search Field
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Social Button
- Sonner
- Spinner
- Status Dot
- Switch
- Switch Card
- Table
- Tabs
- Tag Group
- Textarea
- Theme Toggle
- Time Field
- Toggle
- Toggle Group
- Tooltip
- Trace
- Tree View
- Agent Creator 01
- Agent Limits Card 01
- Agent Progress 01
- AI Chat 01
- AI Image Generation 01
- AI Profile 01
- Alerts Card 01
- Auth Card 01
- Calendar 01
- Composer 01
- Composer Attachments 01
- Composer Panel 01
- Finance Dashboard 01
- Home Dashboard 01
- Marketing Dashboard 01
- Medical Profile 01
- Meeting Scheduler 01
- Multi Agent Chat 01
- Notification Center 01
- Patient Info Card 01
- Project Board 01
- Questionnaire 01
- Settings Modal 01
- Sidebar 01
- Stat Cards 01
- Task List 01
- Web Search 01
- Activity Rings Card 01
- Area Chart Card 01
- Bar List Card 01
- Combo Chart Card 01
- Contributions Card 01
- Earnings Chart Card 01
- Funnel Chart Card 01
- Heatmap Chart Card 01
- Line Chart Card 01
- Most Active Days Card 01
- Orders Chart Card 01
- Radar Chart Card 01
- Radial Chart Card 01
- Revenue Chart Card 01
- Sankey Chart Card 01
- Scatter Chart Card 01
- Sleep Score Card 01
- Stage Bars Card 01
- Steps Card 01
One seat per teammate, up to 20.
Shown on the invoice before tax.
Arrow keys step by one percent.
Type it, or step it with the arrow keys.
Locked by the workspace plan.
Enter a number between 1 and 200.
Installation
$ pnpm dlx shadcn@latest add @cobalt/number-field
Usage
import { NumberField } from "@/components/ui/number-field";
<NumberField label="Seats" defaultValue={5} min={1} max={20} />;The field formats and parses the value for the reader's locale. Pass Base UI's
format for currency, percent and unit styles, and keep the value inside
min and max — the steppers switch off at either limit:
<NumberField
label="Hourly rate"
defaultValue={49}
min={0}
step={5}
format={{ style: "currency", currency: "USD" }}
/>size="sm" gives the 32px field instead of the 36px one. hideSteppers takes
the buttons away while the keys keep working. label and hint are wired to
the input, required adds the asterisk, and tooltip shows an info icon next
to the label. aria-invalid fills the field with the invalid surface and turns
the hint red.
Keyboard
ArrowUpandArrowDownstep the value; holdAltfor the small step andShiftfor the large one.HomeandEndjump tominandmaxwhen they are set.- The scroll wheel steps the value while the field has focus.
- Holding a stepper repeats it. The steppers stay out of the tab order because the keys do the same job.
Source
"use client";
import { NumberField as NumberFieldPrimitive } from "@base-ui/react/number-field";
import { RiInformationLine, RiSubtractLine, RiAddLine } from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";
import { Label } from "@/components/ui/label";
type IconComponent = React.ComponentType<{
className?: string;
"aria-hidden"?: boolean | "true" | "false";
}>;
// The shell is Input's: same 10px corners, 36/32px heights, surface, inset
// ring and 150ms transition. Hover lifts the ring one step and focus takes it
// to the active step, except while disabled or invalid.
const shellClassName = cn(
"relative flex w-full items-center rounded-[10px] bg-[var(--surface-strong,var(--muted))] ring-2 ring-transparent ring-inset outline-none",
"transition-[background-color,box-shadow,color] duration-150 ease-[ease] motion-reduce:transition-none",
"hover:not-focus-within:not-has-[input:disabled]:not-has-[input[aria-invalid=true]]:ring-[var(--border-hover,var(--border))]",
// cobalt-allow-focus-within -- text input (number input)
"focus-within:not-has-[input:disabled]:not-has-[input[aria-invalid=true]]:ring-[var(--foreground-subtle,var(--ring))]",
"data-disabled:bg-[var(--input-disabled-surface,var(--muted))]",
"data-[invalid=true]:bg-[var(--input-invalid-surface,color-mix(in_srgb,var(--destructive)_15%,transparent))]"
);
// 24px (20px with `sm`) steppers. The resting colour is the source's
// foreground/icon/secondary (neutral-500 in both modes), hover fills with
// background/tertiary/hover and lifts the glyph to text/primary, and a
// disabled stepper at either limit keeps the disabled input text colour.
// React Aria's data-pressed has no Base UI counterpart, so :active stands in
// for it.
const stepperClassName = cn(
"flex shrink-0 cursor-pointer items-center justify-center rounded-[6px] outline-none",
"text-[color:var(--foreground-secondary,var(--muted-foreground))]",
"transition-colors duration-150 ease-out motion-reduce:transition-none",
"[&:hover:not([data-disabled])]:bg-[var(--border-strong,color-mix(in_oklch,var(--foreground)_12%,transparent))] [&:hover:not([data-disabled])]:text-foreground",
"[&:active:not([data-disabled])]:bg-[var(--border-strong,color-mix(in_oklch,var(--foreground)_12%,transparent))]",
"focus-visible:ring-2 focus-visible:ring-ring",
"data-disabled:cursor-not-allowed data-disabled:text-[color:var(--input-disabled-foreground,var(--muted-foreground))]"
);
const startPadding = {
icon: { default: "ps-1", sm: "ps-0.5" },
plain: { default: "ps-2", sm: "ps-1.5" },
};
export interface NumberFieldProps extends Omit<
NumberFieldPrimitive.Root.Props,
"children" | "className" | "inputRef"
> {
/** Label rendered above the field and wired to the input with `htmlFor`. */
label?: React.ReactNode;
/** Caption rendered below the field and wired with `aria-describedby`. */
hint?: React.ReactNode;
/** Show an info icon next to the label. */
tooltip?: boolean | string;
placeholder?: string;
/** 36px (`default`) or 32px (`sm`). */
size?: "default" | "sm";
leadingIcon?: IconComponent;
/** Hide the minus and plus buttons; the keys still step the value. */
hideSteppers?: boolean;
className?: string;
fieldClassName?: string;
/** Ref to the visible input. */
inputRef?: React.Ref<HTMLInputElement>;
}
function NumberField({
label,
hint,
tooltip,
leadingIcon: Leading,
hideSteppers = false,
size = "default",
allowWheelScrub = true,
required = false,
className,
fieldClassName,
id,
placeholder,
inputRef,
"aria-label": ariaLabel,
"aria-invalid": ariaInvalid,
"aria-describedby": ariaDescribedBy,
...rootProps
}: NumberFieldProps) {
const generatedId = React.useId();
const inputId = id ?? generatedId;
const hintId = React.useId();
const isInvalid = ariaInvalid === true || ariaInvalid === "true";
const stepperSize = size === "sm" ? "size-5" : "size-6";
return (
<NumberFieldPrimitive.Root
{...rootProps}
id={inputId}
required={required}
allowWheelScrub={allowWheelScrub}
data-slot="number-field"
className={cn(
"group/number-field flex w-full flex-col items-start gap-1",
className
)}
>
{label ? (
<Label
data-slot="number-field-label"
htmlFor={inputId}
className="gap-0.5 leading-5"
>
{label}
{required && (
<span aria-hidden="true" className="text-destructive">
*
</span>
)}
{tooltip ? (
<RiInformationLine
aria-hidden={true}
className="size-4 shrink-0 text-[color:var(--border-strong,var(--foreground-subtle,var(--muted-foreground)))]"
/>
) : null}
</Label>
) : null}
<NumberFieldPrimitive.Group
data-slot="number-field-control"
data-invalid={isInvalid ? "true" : undefined}
className={cn(
shellClassName,
size === "default" ? "h-9 pe-2 py-2" : "h-8 pe-1.5 py-2",
// Input pads 8px all round; with a leading icon the icon carries 4px of it.
startPadding[Leading ? "icon" : "plain"][size],
fieldClassName
)}
>
<div className="flex w-full min-w-0 items-center gap-2">
<div className="flex min-w-0 flex-1 items-center gap-0.5">
{Leading ? (
<Leading
aria-hidden={true}
className="ms-1 size-5 shrink-0 text-[color:var(--foreground-subtle,var(--muted-foreground))]"
/>
) : null}
<NumberFieldPrimitive.Input
ref={inputRef}
data-slot="number-field-input"
placeholder={placeholder}
aria-label={
ariaLabel ??
(!label && typeof placeholder === "string"
? placeholder
: undefined)
}
aria-invalid={ariaInvalid}
aria-describedby={
[ariaDescribedBy, hint ? hintId : undefined]
.filter(Boolean)
.join(" ") || undefined
}
className={cn(
"m-0 min-w-0 flex-1 border-0 bg-transparent p-0 ps-1 text-start font-sans text-sm leading-5 font-normal text-foreground tabular-nums outline-none",
"placeholder:text-[color:var(--foreground-subtle,var(--muted-foreground))] focus:placeholder:text-foreground",
"disabled:cursor-not-allowed disabled:text-[color:var(--input-disabled-foreground,var(--muted-foreground))] disabled:placeholder:text-[color:var(--input-disabled-foreground,var(--muted-foreground))]",
"aria-invalid:placeholder:text-[color:var(--input-invalid-placeholder,var(--destructive))]"
)}
/>
</div>
{hideSteppers ? null : (
// -me-1 pulls the pair into the shell's 8px padding, leaving 4px.
<span className="-my-1 -me-1 flex shrink-0 items-center gap-0.5">
<NumberFieldPrimitive.Decrement
className={cn(stepperClassName, stepperSize)}
>
<RiSubtractLine aria-hidden={true} className="size-4" />
</NumberFieldPrimitive.Decrement>
<NumberFieldPrimitive.Increment
className={cn(stepperClassName, stepperSize)}
>
<RiAddLine aria-hidden={true} className="size-4" />
</NumberFieldPrimitive.Increment>
</span>
)}
</div>
</NumberFieldPrimitive.Group>
{hint ? (
<p
id={hintId}
data-slot="number-field-hint"
className={cn(
"pt-px text-[12px] leading-4 font-medium tracking-[0.15px]",
isInvalid ? "text-destructive" : "text-muted-foreground"
)}
>
{hint}
</p>
) : null}
</NumberFieldPrimitive.Root>
);
}
export { NumberField }; On this page