Unit input groups
Five inputs with unit and currency affixes: a fixed unit chip, a currency pair, a percent icon, a real unit select and a working stepper whose plus and minus buttons clamp the number between 0 and 100. Each wraps a real labelled input.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/input-group-unitDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install lucide-reactCopy the source
components/ui/input-group-unit.tsx"use client"
import * as React from "react"
import { Minus, Percent, Plus } from "lucide-react"
import { cn } from "@/lib/utils"
/* Unit input group family: 5 fields with a unit/currency attached. unit (a fixed
unit chip on the right), currency (a currency chip on the left), percent (a
percent icon on the right), selectUnit (a unit selector on the right), stepper (a
REALLY working +/- stepper for a number field; the value is held in local state
and clamped to min/max). Color comes ONLY from tokens, via alpha color-mix. No
motion. Every field has an accessible label and every icon button an
aria-label. */
export type StyledSize = "sm" | "md" | "lg" | "xl"
const height: Record<StyledSize, string> = {
sm: "h-8 text-sm",
md: "h-9 text-sm",
lg: "h-10 text-base",
xl: "h-12 text-base",
}
const rootBase =
"group inline-flex w-64 max-w-full items-center overflow-hidden rounded-lg border border-field-border bg-transparent transition-colors duration-(--motion-base) focus-within:border-primary focus-within:ring-[3px] focus-within:ring-ring/50 [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:text-base [&_i]:leading-none"
const fieldBase =
"h-full w-full min-w-0 bg-transparent px-3 text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50"
const addonBase =
"flex h-full shrink-0 select-none items-center gap-1.5 bg-surface-2 px-3 text-muted-foreground [&_svg]:size-4 [&_svg]:shrink-0 [&_i]:text-base [&_i]:leading-none"
const stepBtn =
"flex h-full w-9 shrink-0 items-center justify-center bg-surface-2 text-muted-foreground outline-none transition-colors hover:bg-surface-3 hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
type Props = Omit<React.ComponentProps<"input">, "size"> & { size?: StyledSize }
/* Unit: a fixed unit chip on the right (for example "px"). */
export function UnitGroup({ className, size = "md", ...props }: Props) {
const id = React.useId()
return (
<div data-slot="styled-input-group" className={cn(rootBase, height[size], className)}>
<label htmlFor={id} className="sr-only">
Length in pixels
</label>
<input id={id} type="number" placeholder="0" {...props} className={cn(fieldBase, "pr-2")} />
<span className={cn(addonBase, "border-l border-field-border")}>px</span>
</div>
)
}
/* Currency: a currency chip on the left, the code on the right. */
export function CurrencyGroup({ className, size = "md", ...props }: Props) {
const id = React.useId()
return (
<div data-slot="styled-input-group" className={cn(rootBase, height[size], className)}>
<span className={cn(addonBase, "border-r border-field-border")}>$</span>
<label htmlFor={id} className="sr-only">
Amount in dollars
</label>
<input id={id} type="number" placeholder="0.00" {...props} className={cn(fieldBase, "px-2")} />
<span className="mr-3 shrink-0 select-none text-xs text-muted-foreground">USD</span>
</div>
)
}
/* Percent: sagda yuzde ikonu tasiyan oran alani. */
export function PercentGroup({ className, size = "md", ...props }: Props) {
const id = React.useId()
return (
<div data-slot="styled-input-group" className={cn(rootBase, height[size], className)}>
<label htmlFor={id} className="sr-only">
Percentage
</label>
<input id={id} type="number" placeholder="0" {...props} className={cn(fieldBase, "pr-2")} />
<span className={cn(addonBase, "border-l border-field-border")}>
<Percent />
</span>
</div>
)
}
/* SelectUnit: sagda gercek bir birim secimi. */
export function SelectUnitGroup({ className, size = "md", ...props }: Props) {
const id = React.useId()
return (
<div data-slot="styled-input-group" className={cn(rootBase, height[size], className)}>
<label htmlFor={id} className="sr-only">
Size value
</label>
<input id={id} type="number" placeholder="0" {...props} className={cn(fieldBase, "pr-2")} />
<select
aria-label="Unit"
className="h-full shrink-0 select-none border-l border-field-border bg-surface-2 pl-3 pr-2 text-muted-foreground outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<option value="px">px</option>
<option value="rem">rem</option>
<option value="%">%</option>
</select>
</div>
)
}
/* Stepper: a REAL working +/- stepper. The value lives in local state (a controlled number input), clamped between 0 and 100; the buttons disable at the limits. */
export function StepperGroup({ className, size = "md", ...props }: Props) {
const id = React.useId()
const MIN = 0
const MAX = 100
const [value, setValue] = React.useState(1)
const clamp = (n: number) => Math.min(MAX, Math.max(MIN, n))
return (
<div data-slot="styled-input-group" className={cn(rootBase, height[size], className)}>
<button
type="button"
aria-label="Decrease value"
disabled={value <= MIN}
className={cn(stepBtn, "border-r border-field-border")}
onClick={() => setValue((v) => clamp(v - 1))}
>
<Minus />
</button>
<label htmlFor={id} className="sr-only">
Quantity
</label>
<input
id={id}
type="number"
min={MIN}
max={MAX}
{...props}
value={value}
onChange={(e) => {
const next = Number(e.target.value)
setValue(Number.isFinite(next) ? clamp(next) : MIN)
}}
className={cn(fieldBase, "text-center [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none")}
/>
<button
type="button"
aria-label="Increase value"
disabled={value >= MAX}
className={cn(stepBtn, "border-l border-field-border")}
onClick={() => setValue((v) => clamp(v + 1))}
>
<Plus />
</button>
</div>
)
}Manual installs skip the @ai2/tokens theme, so add the token CSS from the theming guide or the tone colors will be missing.
Variations
5 takes on the same idea. Each is its own export, and every one accepts a size prop (sm, md, lg, xl) aligned to the base Button scale.
Unit
A fixed unit chip attached on the right.
import { UnitGroup } from "@/components/ui/input-group-unit"
<UnitGroup placeholder="0" />Currency
A currency symbol on the left and the code on the right.
import { CurrencyGroup } from "@/components/ui/input-group-unit"
<CurrencyGroup placeholder="0" />Percent
A ratio field with a trailing percent icon.
import { PercentGroup } from "@/components/ui/input-group-unit"
<PercentGroup placeholder="0" />Select unit
A real unit select attached on the right.
import { SelectUnitGroup } from "@/components/ui/input-group-unit"
<SelectUnitGroup placeholder="0" />Stepper
A working plus and minus stepper on a clamped number input.
import { StepperGroup } from "@/components/ui/input-group-unit"
<StepperGroup />ai2 Unit input groups: 5 styled variations on the token system
The ai2 Unit input groups are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around inputs with unit and currency affixes and a working stepper. They are free and MIT licensed, and every color comes from a semantic token, so they theme with the rest of ai2 in light and dark.
Motion runs on framer-motion: focus-within highlights run on token CSS transitions. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, the transitions are disabled and the stepper still works.
What is in the ai2 Unit input groups?
5 exports in one file: Unit, Currency, Percent, Select unit and Stepper. Each renders a native button and takes a size prop (sm, md, lg, xl) aligned to the base Button. They are separate from the base Button on purpose: the base keeps its clean variant, tone and size axes, while the styled layer carries the effects.
You own the file. Copy the one category file and you have all 5 variations, with no runtime dependency on ai2 itself.
Why use it
- On-system by construction: Every color resolves to an ai2 semantic token, so the buttons follow your theme in light and dark with no extra work.
- Effect without the sprawl: The decorations live in a dedicated styled file, so the base Button keeps its clean, predictable API.
- Accessible and honest: Each renders a real button element, keeps a visible focus ring, and respects prefers-reduced-motion.
Features
- Token-driven color: No hardcoded hex or oklch; the look recolors with your theme tokens.
- framer-motion: focus-within highlights run on token CSS transitions.
- Reduced-motion aware: Under prefers-reduced-motion, the transitions are disabled and the stepper still works.
- Size aligned to the base: Every variation takes sm, md, lg and xl matching the base Button height scale, so styled and base buttons line up in a row.
Production tips
- Use it for emphasis, not everywhere: Styled buttons draw the eye. Reserve them for the one action you want people to take on a screen, and use the base Button for the rest.
- Keep labels as verbs: The decoration adds weight, so a clear action label keeps the button scannable.
- Pick one variation per surface: The variations share a family; using two different ones in the same view competes for attention.
Works with the rest of ai2
The Unit input groups sit alongside the base Button and the rest of the @ai2 registry. They share the same token file, so a styled action next to a base button or a badge stays visually consistent in both modes.