Number Field
Free, copy-and-go Number Field components built on the SevenUI Number Field primitive.Read the primitive docs.
"use client";
import { Label } from "@/components/ui/label";
import {
NumberField,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
} from "@/components/ui/number-field";
const sizes = [
{
id: "number-field-01-sm",
label: "Rows per page",
hint: "Small · dense tables and toolbars",
defaultValue: 25,
step: 5,
min: 5,
max: 100,
group: "h-7",
stepper: "w-7 [&_svg]:size-3.5",
input: "w-12 text-xs",
},
{
id: "number-field-01-md",
label: "Guests",
hint: "Default · forms and settings",
defaultValue: 2,
step: 1,
min: 1,
max: 12,
group: "h-9",
stepper: "",
input: "",
},
{
id: "number-field-01-lg",
label: "Tickets",
hint: "Large · touch and checkout flows",
defaultValue: 4,
step: 1,
min: 1,
max: 10,
group: "h-11 rounded-lg",
stepper: "w-11 [&_svg]:size-5",
input: "w-20 text-base font-medium",
},
];
export default function NumberField01() {
return (
<div className="flex w-full max-w-sm flex-col divide-y divide-border">
{sizes.map((size) => (
<div
key={size.id}
className="flex items-center justify-between gap-4 py-4 first:pt-0 last:pb-0"
>
<div className="flex min-w-0 flex-col gap-1">
<Label htmlFor={size.id}>{size.label}</Label>
<span className="text-xs text-muted-foreground">{size.hint}</span>
</div>
<NumberField
id={size.id}
defaultValue={size.defaultValue}
step={size.step}
min={size.min}
max={size.max}
className="shrink-0"
>
<NumberFieldGroup className={size.group}>
<NumberFieldDecrement className={size.stepper} />
<NumberFieldInput className={size.input} />
<NumberFieldIncrement className={size.stepper} />
</NumberFieldGroup>
</NumberField>
</div>
))}
</div>
);
}
npx shadcn@latest add @sevenui/component/number-field-01pnpm dlx shadcn@latest add @sevenui/component/number-field-01yarn dlx shadcn@latest add @sevenui/component/number-field-01bunx --bun shadcn@latest add @sevenui/component/number-field-01"use client";
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { Label } from "@/components/ui/label";
import {
NumberField,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
} from "@/components/ui/number-field";
const fields = [
{
id: "number-field-02-size",
label: "Font size",
unit: "px",
defaultValue: 16,
step: 1,
min: 8,
max: 96,
format: undefined,
},
{
id: "number-field-02-leading",
label: "Line height",
unit: "×",
defaultValue: 1.5,
step: 0.05,
min: 1,
max: 3,
format: { minimumFractionDigits: 2, maximumFractionDigits: 2 },
},
];
const stackedStepper =
"h-auto w-7 flex-1 border-l-0 bg-transparent [&_svg]:size-3.5";
export default function NumberField02() {
return (
<div className="grid w-full max-w-sm grid-cols-1 gap-4 min-[360px]:grid-cols-2">
{fields.map((field) => (
<NumberField
key={field.id}
id={field.id}
defaultValue={field.defaultValue}
step={field.step}
min={field.min}
max={field.max}
format={field.format}
>
<Label htmlFor={field.id}>{field.label}</Label>
<NumberFieldGroup className="w-full">
<NumberFieldInput className="min-w-0 flex-1 pl-3 text-left" />
<span
aria-hidden="true"
className="flex items-center pr-2 text-xs text-muted-foreground"
>
{field.unit}
</span>
<div className="flex flex-col border-l border-input bg-muted/50">
<NumberFieldIncrement
aria-label={`Increase ${field.label.toLowerCase()}`}
className={`${stackedStepper} border-b border-input`}
render={(props) => (
<button {...props} type="button">
<ChevronUpIcon aria-hidden="true" />
</button>
)}
/>
<NumberFieldDecrement
aria-label={`Decrease ${field.label.toLowerCase()}`}
className={`${stackedStepper} border-r-0`}
render={(props) => (
<button {...props} type="button">
<ChevronDownIcon aria-hidden="true" />
</button>
)}
/>
</div>
</NumberFieldGroup>
</NumberField>
))}
</div>
);
}
npx shadcn@latest add @sevenui/component/number-field-02pnpm dlx shadcn@latest add @sevenui/component/number-field-02yarn dlx shadcn@latest add @sevenui/component/number-field-02bunx --bun shadcn@latest add @sevenui/component/number-field-02"use client";
import { MoonIcon } from "lucide-react";
import { Label } from "@/components/ui/label";
import {
NumberField,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
} from "@/components/ui/number-field";
const pillStepper =
"size-8 rounded-full border-0 bg-background text-foreground shadow-sm hover:bg-background hover:text-foreground active:scale-95 transition-[transform,background-color,color] dark:bg-accent dark:hover:bg-accent/80";
export default function NumberField03() {
return (
<NumberField
id="number-field-03"
defaultValue={3}
min={1}
max={28}
className="flex w-full max-w-xs flex-row items-center justify-between gap-4"
>
<div className="flex min-w-0 items-center gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<MoonIcon aria-hidden="true" className="size-4" />
</span>
<div className="flex min-w-0 flex-col gap-1">
<Label htmlFor="number-field-03">Nights</Label>
<span className="text-xs text-muted-foreground">
Check-in <span className="whitespace-nowrap">Fri, Mar 14</span>
</span>
</div>
</div>
<NumberFieldGroup className="h-10 shrink-0 items-center gap-1 rounded-full border-0 bg-muted p-1 shadow-none focus-within:ring-2">
<NumberFieldDecrement className={pillStepper} />
<NumberFieldInput className="w-8 font-medium" />
<NumberFieldIncrement className={pillStepper} />
</NumberFieldGroup>
</NumberField>
);
}
npx shadcn@latest add @sevenui/component/number-field-03pnpm dlx shadcn@latest add @sevenui/component/number-field-03yarn dlx shadcn@latest add @sevenui/component/number-field-03bunx --bun shadcn@latest add @sevenui/component/number-field-03"use client";
import { PercentIcon, ScaleIcon } from "lucide-react";
import { Label } from "@/components/ui/label";
import {
NumberField,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
} from "@/components/ui/number-field";
const affixClasses =
"flex shrink-0 items-center px-3 text-sm text-muted-foreground [&_svg]:size-4";
export default function NumberField04() {
return (
<div className="flex w-full max-w-xs flex-col gap-5">
<NumberField
id="number-field-04-price"
defaultValue={49}
min={0}
step={1}
largeStep={10}
format={{ minimumFractionDigits: 2, maximumFractionDigits: 2 }}
>
<Label htmlFor="number-field-04-price">Unit price</Label>
<NumberFieldGroup className="w-full">
<span aria-hidden="true" className={`${affixClasses} pr-1`}>
$
</span>
<NumberFieldInput className="min-w-0 flex-1 text-left" />
<span aria-hidden="true" className={`${affixClasses} text-xs`}>
USD
</span>
<NumberFieldDecrement aria-label="Decrease unit price" />
<NumberFieldIncrement aria-label="Increase unit price" />
</NumberFieldGroup>
</NumberField>
<NumberField
id="number-field-04-weight"
defaultValue={2.4}
min={0}
max={30}
step={0.1}
format={{ minimumFractionDigits: 1, maximumFractionDigits: 1 }}
>
<Label htmlFor="number-field-04-weight">Parcel weight</Label>
<NumberFieldGroup className="w-full">
<span
aria-hidden="true"
className={`${affixClasses} border-r border-input bg-muted/50`}
>
<ScaleIcon />
</span>
<NumberFieldInput className="min-w-0 flex-1 pl-3 text-left" />
<span aria-hidden="true" className={affixClasses}>
kg
</span>
<NumberFieldDecrement aria-label="Decrease parcel weight" />
<NumberFieldIncrement aria-label="Increase parcel weight" />
</NumberFieldGroup>
</NumberField>
<NumberField
id="number-field-04-discount"
defaultValue={15}
min={0}
max={100}
step={5}
>
<Label htmlFor="number-field-04-discount">Promo discount</Label>
<NumberFieldGroup className="w-full">
<NumberFieldInput className="min-w-0 flex-1 pl-3 text-left" />
<span
aria-hidden="true"
className={`${affixClasses} border-l border-input bg-muted/50`}
>
<PercentIcon />
</span>
<NumberFieldDecrement aria-label="Decrease promo discount" />
<NumberFieldIncrement aria-label="Increase promo discount" />
</NumberFieldGroup>
</NumberField>
</div>
);
}
npx shadcn@latest add @sevenui/component/number-field-04pnpm dlx shadcn@latest add @sevenui/component/number-field-04yarn dlx shadcn@latest add @sevenui/component/number-field-04bunx --bun shadcn@latest add @sevenui/component/number-field-04Read-only. Seats sync from your identity provider.
Disabled. Custom retention is available on Enterprise.
"use client";
import { useState } from "react";
import { LockIcon } from "lucide-react";
import {
Field,
FieldDescription,
FieldError,
FieldLabel,
} from "@/components/ui/field";
import {
NumberField,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
} from "@/components/ui/number-field";
const groupClasses =
"w-full data-disabled:bg-muted/40 data-disabled:opacity-60 data-invalid:border-destructive data-invalid:focus-within:ring-destructive/30 data-readonly:bg-muted/40 data-readonly:shadow-none";
const uploadCap = 500;
export default function NumberField05() {
const [uploadSize, setUploadSize] = useState<number | null>(750);
const overCap = uploadSize !== null && uploadSize > uploadCap;
return (
<div className="flex w-full max-w-xs flex-col gap-6">
<Field>
<FieldLabel>Licensed seats</FieldLabel>
<NumberField defaultValue={48} readOnly>
<NumberFieldGroup className={groupClasses}>
<NumberFieldInput className="min-w-0 flex-1 pl-3 text-left" />
<span className="flex items-center px-3 text-muted-foreground">
<LockIcon aria-hidden="true" className="size-3.5" />
</span>
</NumberFieldGroup>
</NumberField>
<FieldDescription>
Read-only. Seats sync from your identity provider.
</FieldDescription>
</Field>
<Field disabled>
<FieldLabel>Log retention (days)</FieldLabel>
<NumberField defaultValue={30} disabled>
<NumberFieldGroup className={groupClasses}>
<NumberFieldDecrement />
<NumberFieldInput className="min-w-0 flex-1" />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
<FieldDescription>
Disabled. Custom retention is available on Enterprise.
</FieldDescription>
</Field>
<Field invalid={overCap}>
<FieldLabel>Max upload size (MB)</FieldLabel>
<NumberField
value={uploadSize}
onValueChange={setUploadSize}
min={1}
step={50}
>
<NumberFieldGroup className={groupClasses}>
<NumberFieldDecrement />
<NumberFieldInput className="min-w-0 flex-1" />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
{overCap ? (
<FieldError match>
Your plan caps uploads at {uploadCap} MB. Lower the limit or
upgrade.
</FieldError>
) : (
<FieldDescription>Within your plan's {uploadCap} MB cap.</FieldDescription>
)}
</Field>
</div>
);
}
npx shadcn@latest add @sevenui/component/number-field-05pnpm dlx shadcn@latest add @sevenui/component/number-field-05yarn dlx shadcn@latest add @sevenui/component/number-field-05bunx --bun shadcn@latest add @sevenui/component/number-field-05Frame
Drag a letter to scrub
"use client";
import { RotateCwIcon } from "lucide-react";
import {
NumberField,
NumberFieldGroup,
NumberFieldInput,
NumberFieldScrubArea,
} from "@/components/ui/number-field";
const properties = [
{ id: "x", handle: "X", label: "X position", value: 240, suffix: "px" },
{ id: "y", handle: "Y", label: "Y position", value: 128, suffix: "px" },
{ id: "w", handle: "W", label: "Width", value: 360, suffix: "px", min: 1 },
{ id: "h", handle: "H", label: "Height", value: 212, suffix: "px", min: 1 },
];
export default function NumberField06() {
return (
<section
aria-labelledby="number-field-06-title"
className="w-full max-w-xs rounded-xl border border-border bg-card p-4 text-card-foreground"
>
<div className="mb-3 flex items-baseline justify-between gap-2">
<h3 id="number-field-06-title" className="text-sm font-medium">
Frame
</h3>
<p className="text-xs text-muted-foreground">Drag a letter to scrub</p>
</div>
<div className="grid grid-cols-2 gap-2">
{properties.map((property) => {
const id = `number-field-06-${property.id}`;
return (
<NumberField
key={property.id}
id={id}
defaultValue={property.value}
min={property.min}
largeStep={10}
>
<NumberFieldGroup className="h-8 w-full bg-muted/40 shadow-none">
<NumberFieldScrubArea className="flex w-7 shrink-0 items-center justify-center text-xs font-medium text-muted-foreground transition-colors hover:text-foreground data-scrubbing:bg-muted data-scrubbing:text-foreground">
<label htmlFor={id} className="cursor-ew-resize">
{property.handle}
</label>
</NumberFieldScrubArea>
<NumberFieldInput
aria-label={property.label}
className="w-0 min-w-0 flex-1 text-left text-xs"
/>
<span
aria-hidden="true"
className="flex items-center pr-2 text-xs text-muted-foreground"
>
{property.suffix}
</span>
</NumberFieldGroup>
</NumberField>
);
})}
<NumberField
id="number-field-06-rotation"
defaultValue={15}
min={-180}
max={180}
largeStep={45}
className="col-span-2"
>
<NumberFieldGroup className="h-8 w-full bg-muted/40 shadow-none">
<NumberFieldScrubArea className="flex w-7 shrink-0 items-center justify-center text-muted-foreground transition-colors hover:text-foreground data-scrubbing:bg-muted data-scrubbing:text-foreground">
<label
htmlFor="number-field-06-rotation"
className="cursor-ew-resize"
>
<RotateCwIcon aria-hidden="true" className="size-3.5" />
<span className="sr-only">Rotation</span>
</label>
</NumberFieldScrubArea>
<NumberFieldInput className="w-0 min-w-0 flex-1 text-left text-xs" />
<span
aria-hidden="true"
className="flex items-center pr-2 text-xs text-muted-foreground"
>
deg
</span>
</NumberFieldGroup>
</NumberField>
</div>
</section>
);
}
npx shadcn@latest add @sevenui/component/number-field-06pnpm dlx shadcn@latest add @sevenui/component/number-field-06yarn dlx shadcn@latest add @sevenui/component/number-field-06bunx --bun shadcn@latest add @sevenui/component/number-field-06"use client";
import { useState } from "react";
import { RotateCcwIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
NumberField,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
} from "@/components/ui/number-field";
import { Slider } from "@/components/ui/slider";
const min = 0;
const max = 5000;
const step = 50;
const defaultCap = 1200;
const currencyFormat = {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
} as const;
const currency = new Intl.NumberFormat("en-US", currencyFormat);
export default function NumberField07() {
const [cap, setCap] = useState(defaultCap);
return (
<div className="flex w-full max-w-sm flex-col gap-4">
<div className="flex items-end justify-between gap-3">
<div className="flex min-w-0 flex-col gap-1">
<Label htmlFor="number-field-07" id="number-field-07-label">
Monthly spend cap
</Label>
<span className="text-xs text-muted-foreground">
API usage pauses once the cap is reached.
</span>
</div>
<Button
variant="ghost"
size="icon-sm"
aria-label="Reset spend cap"
disabled={cap === defaultCap}
onClick={() => setCap(defaultCap)}
>
<RotateCcwIcon aria-hidden="true" />
</Button>
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
<div className="flex flex-1 flex-col gap-2 sm:pt-3">
<Slider
aria-labelledby="number-field-07-label"
min={min}
max={max}
step={step}
value={[cap]}
onValueChange={(value) =>
setCap(typeof value === "number" ? value : value[0])
}
/>
<div
aria-hidden="true"
className="flex justify-between text-xs tabular-nums text-muted-foreground"
>
<span>{currency.format(min)}</span>
<span>{currency.format(max)}</span>
</div>
</div>
<NumberField
id="number-field-07"
value={cap}
onValueChange={(value) => setCap(value ?? min)}
min={min}
max={max}
step={step}
largeStep={500}
format={currencyFormat}
className="shrink-0"
>
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput className="w-20" />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
</div>
</div>
);
}
npx shadcn@latest add @sevenui/component/number-field-07pnpm dlx shadcn@latest add @sevenui/component/number-field-07yarn dlx shadcn@latest add @sevenui/component/number-field-07bunx --bun shadcn@latest add @sevenui/component/number-field-07Saves when you leave the field. Current: 250 GB.
"use client";
import { useEffect, useRef, useState } from "react";
import { CheckIcon, HardDriveIcon, Loader2Icon } from "lucide-react";
import { cn } from "cn";
import { Label } from "@/components/ui/label";
import {
NumberField,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
} from "@/components/ui/number-field";
type Status = "idle" | "saving" | "saved";
const savedQuota = 250;
export default function NumberField08() {
const [status, setStatus] = useState<Status>("idle");
const [committed, setCommitted] = useState(savedQuota);
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
useEffect(() => {
const pending = timers.current;
return () => {
for (const timer of pending) clearTimeout(timer);
};
}, []);
function handleCommit(value: number | null) {
if (value === null || value === committed) return;
for (const timer of timers.current) clearTimeout(timer);
timers.current.length = 0;
setStatus("saving");
timers.current.push(
setTimeout(() => {
setCommitted(value);
setStatus("saved");
}, 900),
setTimeout(() => setStatus("idle"), 3200),
);
}
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<Label htmlFor="number-field-08">Storage quota per workspace</Label>
<NumberField
id="number-field-08"
defaultValue={savedQuota}
min={10}
max={2000}
step={10}
largeStep={100}
onValueCommitted={handleCommit}
>
<NumberFieldGroup
data-status={status}
className="w-full transition-[border-color,box-shadow] duration-300 data-[status=saved]:border-success/60 data-[status=saved]:ring-3 data-[status=saved]:ring-success/15"
>
<span
aria-hidden="true"
className="flex items-center border-r border-input bg-muted/50 px-3 text-muted-foreground"
>
<HardDriveIcon className="size-4" />
</span>
<NumberFieldInput className="min-w-0 flex-1 pl-3 text-left" />
<span className="relative flex w-16 items-center justify-end gap-1.5 pr-3 text-xs text-muted-foreground">
<Loader2Icon
aria-hidden="true"
className={cn(
"absolute left-1 size-3.5 animate-spin transition-[opacity,scale] duration-200",
status === "saving" ? "scale-100 opacity-100" : "scale-50 opacity-0",
)}
/>
<CheckIcon
aria-hidden="true"
className={cn(
"absolute left-1 size-3.5 text-success transition-[opacity,scale] duration-300 ease-out",
status === "saved" ? "scale-100 opacity-100" : "scale-50 opacity-0",
)}
/>
GB
</span>
<NumberFieldDecrement />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
<p
aria-live="polite"
className={cn(
"text-xs transition-colors duration-300",
status === "saved" ? "text-success" : "text-muted-foreground",
)}
>
{status === "saving"
? "Saving…"
: status === "saved"
? `Saved. Workspaces now get ${committed} GB each.`
: `Saves when you leave the field. Current: ${committed} GB.`}
</p>
</div>
);
}
npx shadcn@latest add @sevenui/component/number-field-08pnpm dlx shadcn@latest add @sevenui/component/number-field-08yarn dlx shadcn@latest add @sevenui/component/number-field-08bunx --bun shadcn@latest add @sevenui/component/number-field-08Your cart
3 itemsStoneware mug
Sand, 350 ml
$48.00
Pour-over kettle
Matte black, 1 L
$68.00
Paper filters
Pack of 100
$27.00
- Subtotal
- $143.00
- Shipping
- Free
- Total
- $143.00
"use client";
import * as React from "react";
import { Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
NumberField,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
} from "@/components/ui/number-field";
import { Separator } from "@/components/ui/separator";
const initialItems = [
{
id: "mug",
name: "Stoneware mug",
variant: "Sand, 350 ml",
price: 24,
stock: 12,
quantity: 2,
},
{
id: "kettle",
name: "Pour-over kettle",
variant: "Matte black, 1 L",
price: 68,
stock: 3,
quantity: 1,
},
{
id: "filters",
name: "Paper filters",
variant: "Pack of 100",
price: 9,
stock: 40,
quantity: 3,
},
];
const currency = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
});
export default function NumberField09() {
const [items, setItems] = React.useState(initialItems);
const subtotal = items.reduce(
(sum, item) => sum + item.price * item.quantity,
0,
);
const shipping = subtotal >= 75 || subtotal === 0 ? 0 : 6;
function setQuantity(id: string, quantity: number | null) {
setItems((current) =>
current.map((item) =>
item.id === id ? { ...item, quantity: quantity ?? 1 } : item,
),
);
}
function removeItem(id: string) {
setItems((current) => current.filter((item) => item.id !== id));
}
return (
<section
aria-labelledby="number-field-09-title"
className="w-full max-w-md rounded-xl border bg-card text-card-foreground"
>
<header className="flex items-baseline justify-between px-5 pt-5 pb-3">
<h3 id="number-field-09-title" className="font-semibold">
Your cart
</h3>
<span className="text-sm text-muted-foreground">
{items.length} {items.length === 1 ? "item" : "items"}
</span>
</header>
{items.length === 0 ? (
<div className="flex flex-col items-center gap-3 border-t px-5 py-10 text-center">
<p className="text-sm text-muted-foreground">Your cart is empty.</p>
<Button
variant="outline"
size="sm"
onClick={() => setItems(initialItems)}
>
Restore items
</Button>
</div>
) : (
<ul className="divide-y border-y">
{items.map((item) => (
<li key={item.id} className="flex gap-3 px-5 py-4">
<img
src="/placeholder.svg"
alt=""
className="size-14 shrink-0 rounded-md border bg-muted object-cover"
/>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate text-sm font-medium">{item.name}</p>
<p className="truncate text-xs text-muted-foreground">
{item.variant}
</p>
</div>
<p className="text-sm font-medium tabular-nums">
{currency.format(item.price * item.quantity)}
</p>
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<NumberField
value={item.quantity}
onValueChange={(value) => setQuantity(item.id, value)}
min={1}
max={item.stock}
>
<NumberFieldGroup className="h-8">
<NumberFieldDecrement
aria-label={`Decrease quantity of ${item.name}`}
className="w-8"
/>
<NumberFieldInput
aria-label={`Quantity of ${item.name}`}
className="w-10"
/>
<NumberFieldIncrement
aria-label={`Increase quantity of ${item.name}`}
className="w-8"
/>
</NumberFieldGroup>
</NumberField>
{item.quantity >= item.stock ? (
<span className="text-xs text-muted-foreground">
Only {item.stock} in stock
</span>
) : null}
<Button
variant="ghost"
size="icon-sm"
className="ml-auto"
aria-label={`Remove ${item.name}`}
onClick={() => removeItem(item.id)}
>
<Trash2 aria-hidden="true" />
</Button>
</div>
</div>
</li>
))}
</ul>
)}
<dl className="grid gap-2 px-5 py-4 text-sm">
<div className="flex justify-between">
<dt className="text-muted-foreground">Subtotal</dt>
<dd className="tabular-nums">{currency.format(subtotal)}</dd>
</div>
<div className="flex justify-between">
<dt className="text-muted-foreground">Shipping</dt>
<dd className="tabular-nums">
{shipping === 0 ? "Free" : currency.format(shipping)}
</dd>
</div>
<Separator className="my-1" />
<div className="flex justify-between font-medium">
<dt>Total</dt>
<dd className="tabular-nums" aria-live="polite">
{currency.format(subtotal + shipping)}
</dd>
</div>
</dl>
<div className="px-5 pb-5">
<Button className="w-full" size="lg" disabled={items.length === 0}>
Checkout
</Button>
</div>
</section>
);
}
npx shadcn@latest add @sevenui/component/number-field-09pnpm dlx shadcn@latest add @sevenui/component/number-field-09yarn dlx shadcn@latest add @sevenui/component/number-field-09bunx --bun shadcn@latest add @sevenui/component/number-field-09"use client";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import {
NumberField,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
} from "@/components/ui/number-field";
const INITIAL_SEATS = 8;
const SEATS_IN_USE = 6;
const PRICE_PER_SEAT = 12;
const DAYS_LEFT = 18;
const DAYS_IN_CYCLE = 30;
const currency = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
});
export default function NumberField10() {
const [currentSeats, setCurrentSeats] = React.useState(INITIAL_SEATS);
const [seats, setSeats] = React.useState<number>(INITIAL_SEATS);
const [status, setStatus] = React.useState<"idle" | "updating" | "updated">(
"idle",
);
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
React.useEffect(
() => () => {
if (timer.current) clearTimeout(timer.current);
},
[],
);
const delta = seats - currentSeats;
const monthly = seats * PRICE_PER_SEAT;
const prorated = (delta * PRICE_PER_SEAT * DAYS_LEFT) / DAYS_IN_CYCLE;
let summary: string;
if (delta > 0) {
summary = `Adding ${delta} ${delta === 1 ? "seat" : "seats"}. You'll be charged ${currency.format(prorated)} today for the remaining ${DAYS_LEFT} days.`;
} else if (delta < 0) {
summary = `Removing ${-delta} ${delta === -1 ? "seat" : "seats"}. A credit of ${currency.format(-prorated)} goes toward your next invoice.`;
} else if (status === "updated") {
summary = `Plan updated to ${currentSeats} seats.`;
} else {
summary = "No change to your plan.";
}
function updateSeats() {
if (timer.current) clearTimeout(timer.current);
setStatus("updating");
timer.current = setTimeout(() => {
setCurrentSeats(seats);
setStatus("updated");
}, 700);
}
function changeSeats(value: number | null) {
setSeats(value ?? SEATS_IN_USE);
if (status === "updated") setStatus("idle");
}
return (
<Card className="w-full max-w-sm">
<CardHeader>
<CardTitle>Team seats</CardTitle>
<CardDescription>
{SEATS_IN_USE} of {currentSeats} seats are assigned. Each seat
bills at {currency.format(PRICE_PER_SEAT)} per month.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4">
<div className="flex items-center justify-between gap-4">
<div className="grid min-w-0 gap-1">
<Label htmlFor="number-field-10-seats">Seats</Label>
<p
id="number-field-10-hint"
className="text-xs text-muted-foreground"
>
Can't go below the {SEATS_IN_USE} seats in use
</p>
</div>
<NumberField
id="number-field-10-seats"
value={seats}
onValueChange={changeSeats}
disabled={status === "updating"}
min={SEATS_IN_USE}
max={200}
largeStep={5}
className="shrink-0"
>
<NumberFieldGroup className="h-10">
<NumberFieldDecrement className="w-10" />
<NumberFieldInput
aria-describedby="number-field-10-hint"
className="w-14 text-base font-medium"
/>
<NumberFieldIncrement className="w-10" />
</NumberFieldGroup>
</NumberField>
</div>
<div className="grid gap-1 rounded-lg bg-muted/60 p-3 text-sm">
<div className="flex items-baseline justify-between">
<span className="text-muted-foreground">New monthly total</span>
<span className="font-semibold tabular-nums">
{currency.format(monthly)}
</span>
</div>
<p className="text-xs text-muted-foreground" aria-live="polite">
{summary}
</p>
</div>
</CardContent>
<CardFooter className="justify-end gap-2">
<Button
variant="outline"
disabled={delta === 0 || status === "updating"}
onClick={() => setSeats(currentSeats)}
>
Reset
</Button>
<Button
disabled={delta === 0 || status === "updating"}
onClick={updateSeats}
>
{status === "updating" ? "Updating…" : "Update seats"}
</Button>
</CardFooter>
</Card>
);
}
npx shadcn@latest add @sevenui/component/number-field-10pnpm dlx shadcn@latest add @sevenui/component/number-field-10yarn dlx shadcn@latest add @sevenui/component/number-field-10bunx --bun shadcn@latest add @sevenui/component/number-field-10API usage alerts
Email the billing owners before you hit the monthly request quota.
1.2M of 2M requests used this month
"use client";
import * as React from "react";
import { Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
NumberField,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
} from "@/components/ui/number-field";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
const MONTHLY_QUOTA = 2_000_000;
const CURRENT_USAGE = 1_240_000;
const percent = { style: "unit", unit: "percent" } as const;
const compact = new Intl.NumberFormat("en-US", { notation: "compact" });
export default function NumberField11() {
const [enabled, setEnabled] = React.useState(true);
const [warning, setWarning] = React.useState(75);
const [critical, setCritical] = React.useState(90);
const [repeatHours, setRepeatHours] = React.useState(6);
const [saved, setSaved] = React.useState({
enabled: true,
warning: 75,
critical: 90,
repeatHours: 6,
});
const [justSaved, setJustSaved] = React.useState(false);
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
React.useEffect(
() => () => {
if (timer.current) clearTimeout(timer.current);
},
[],
);
const dirty =
saved.enabled !== enabled ||
saved.warning !== warning ||
saved.critical !== critical ||
saved.repeatHours !== repeatHours;
function save() {
setSaved({ enabled, warning, critical, repeatHours });
setJustSaved(true);
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setJustSaved(false), 2500);
}
const usage = (CURRENT_USAGE / MONTHLY_QUOTA) * 100;
return (
<section
aria-labelledby="number-field-11-title"
className="w-full max-w-md rounded-xl border bg-card text-card-foreground"
>
<div className="flex items-start justify-between gap-4 p-5">
<div className="grid gap-1">
<h3 id="number-field-11-title" className="font-semibold">
API usage alerts
</h3>
<p className="text-sm text-muted-foreground">
Email the billing owners before you hit the monthly request quota.
</p>
</div>
<Switch
aria-label="Enable usage alerts"
checked={enabled}
onCheckedChange={setEnabled}
/>
</div>
<div className="px-5">
<div
className="relative h-2 overflow-hidden rounded-full bg-muted"
role="img"
aria-label={`Current usage ${Math.round(usage)}% of quota, warning at ${warning}%, critical at ${critical}%`}
>
<div
className="h-full rounded-full bg-primary"
style={{ width: `${usage}%` }}
/>
<span
aria-hidden="true"
className="absolute inset-y-0 w-0.5 bg-warning"
style={{ left: `calc(${warning}% - 1px)` }}
/>
<span
aria-hidden="true"
className="absolute inset-y-0 w-0.5 bg-destructive"
style={{ left: `min(calc(${critical}% - 1px), calc(100% - 2px))` }}
/>
</div>
<p className="mt-2 text-xs text-muted-foreground tabular-nums">
{compact.format(CURRENT_USAGE)} of{" "}
{compact.format(MONTHLY_QUOTA)} requests used this month
</p>
</div>
<Separator className="mt-5" />
<fieldset
disabled={!enabled}
className="grid gap-4 p-5 disabled:opacity-60"
>
<legend className="sr-only">Alert thresholds</legend>
<div className="flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<Label htmlFor="number-field-11-warning" className="gap-2">
<span
aria-hidden="true"
className="size-2 rounded-full bg-warning"
/>
Warning at
</Label>
<NumberField
id="number-field-11-warning"
value={warning}
onValueChange={(value) => setWarning(value ?? 50)}
min={50}
max={critical - 5}
step={5}
format={percent}
disabled={!enabled}
className="shrink-0"
>
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput className="w-16" />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
</div>
<div className="flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<Label htmlFor="number-field-11-critical" className="gap-2">
<span
aria-hidden="true"
className="size-2 rounded-full bg-destructive"
/>
Critical at
</Label>
<NumberField
id="number-field-11-critical"
value={critical}
onValueChange={(value) => setCritical(value ?? 100)}
min={warning + 5}
max={100}
step={5}
format={percent}
disabled={!enabled}
className="shrink-0"
>
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput className="w-16" />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
</div>
<div className="flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<div className="grid gap-1">
<Label htmlFor="number-field-11-repeat">Remind every</Label>
<p
id="number-field-11-repeat-hint"
className="text-xs text-muted-foreground"
>
Hours between repeat emails
</p>
</div>
<NumberField
id="number-field-11-repeat"
value={repeatHours}
onValueChange={(value) => setRepeatHours(value ?? 1)}
min={1}
max={72}
disabled={!enabled}
className="shrink-0"
>
<NumberFieldGroup>
<NumberFieldDecrement />
<NumberFieldInput
aria-describedby="number-field-11-repeat-hint"
className="w-16"
/>
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
</div>
</fieldset>
<div className="flex items-center justify-end gap-3 border-t px-5 py-4">
<p
aria-live="polite"
className="flex items-center gap-1.5 text-xs text-muted-foreground"
>
{dirty ? (
"Unsaved changes"
) : justSaved ? (
<>
<Check aria-hidden="true" className="size-3.5 text-success" />
Alerts saved
</>
) : null}
</p>
<Button disabled={!dirty} onClick={save}>
Save alerts
</Button>
</div>
</section>
);
}
npx shadcn@latest add @sevenui/component/number-field-11pnpm dlx shadcn@latest add @sevenui/component/number-field-11yarn dlx shadcn@latest add @sevenui/component/number-field-11bunx --bun shadcn@latest add @sevenui/component/number-field-11Intro call
Set how long the meeting runs and the breathing room around it.
Each booking blocks 70 minutes, so invitees can pick from 6 slots between 9:00 AM and 5:00 PM.
"use client";
import * as React from "react";
import { Clock } from "lucide-react";
import { Label } from "@/components/ui/label";
import {
NumberField,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
} from "@/components/ui/number-field";
// First bookable slot of the day, in minutes after midnight (9:00 AM).
const DAY_START = 9 * 60;
const DAY_END = 17 * 60;
const minutes = {
style: "unit",
unit: "minute",
unitDisplay: "short",
} as const;
function formatTime(total: number) {
const hours = Math.floor(total / 60);
const mins = total % 60;
const suffix = hours >= 12 ? "PM" : "AM";
const display = hours % 12 === 0 ? 12 : hours % 12;
return `${display}:${mins.toString().padStart(2, "0")} ${suffix}`;
}
function MinutesField({
id,
label,
value,
onChange,
min,
max,
step,
}: {
id: string;
label: string;
value: number;
onChange: (value: number) => void;
min: number;
max: number;
step: number;
}) {
return (
<NumberField
id={id}
value={value}
onValueChange={(next) => onChange(next ?? min)}
min={min}
max={max}
step={step}
smallStep={5}
format={minutes}
className="gap-1.5"
>
<Label htmlFor={id} className="text-xs text-muted-foreground">
{label}
</Label>
<NumberFieldGroup className="w-full">
<NumberFieldDecrement />
<NumberFieldInput className="w-full min-w-0 flex-1" />
<NumberFieldIncrement />
</NumberFieldGroup>
</NumberField>
);
}
export default function NumberField12() {
const [duration, setDuration] = React.useState(45);
const [before, setBefore] = React.useState(10);
const [after, setAfter] = React.useState(15);
const block = before + duration + after;
const slotsPerDay = Math.floor((DAY_END - DAY_START) / block);
const meetingStart = DAY_START + before;
return (
<section
aria-labelledby="number-field-12-title"
className="grid w-full max-w-md gap-5 rounded-xl border bg-card p-5 text-card-foreground"
>
<div className="flex items-start gap-3">
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
<Clock aria-hidden="true" className="size-4 text-muted-foreground" />
</span>
<div className="grid gap-0.5">
<h3 id="number-field-12-title" className="font-semibold">
Intro call
</h3>
<p className="text-sm text-muted-foreground">
Set how long the meeting runs and the breathing room around it.
</p>
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<MinutesField
id="number-field-12-before"
label="Buffer before"
value={before}
onChange={setBefore}
min={0}
max={60}
step={5}
/>
<MinutesField
id="number-field-12-duration"
label="Duration"
value={duration}
onChange={setDuration}
min={15}
max={240}
step={15}
/>
<MinutesField
id="number-field-12-after"
label="Buffer after"
value={after}
onChange={setAfter}
min={0}
max={60}
step={5}
/>
</div>
<div className="grid gap-2">
<div
className="flex h-10 overflow-hidden rounded-md border"
role="img"
aria-label={`${before} minute buffer, ${duration} minute meeting, ${after} minute buffer`}
>
{before > 0 ? (
<div
className="bg-[repeating-linear-gradient(135deg,var(--muted)_0_6px,transparent_6px_12px)] transition-[flex-grow] duration-300 ease-out"
style={{ flexGrow: before }}
/>
) : null}
<div
className="flex min-w-0 items-center justify-center bg-primary px-2 text-xs font-medium text-primary-foreground transition-[flex-grow] duration-300 ease-out"
style={{ flexGrow: duration }}
>
<span className="truncate tabular-nums">
{formatTime(meetingStart)} –{" "}
{formatTime(meetingStart + duration)}
</span>
</div>
{after > 0 ? (
<div
className="bg-[repeating-linear-gradient(135deg,var(--muted)_0_6px,transparent_6px_12px)] transition-[flex-grow] duration-300 ease-out"
style={{ flexGrow: after }}
/>
) : null}
</div>
<p className="text-xs text-muted-foreground" aria-live="polite">
Each booking blocks {block} minutes, so invitees can pick from{" "}
<span className="font-medium text-foreground tabular-nums">
{slotsPerDay} {slotsPerDay === 1 ? "slot" : "slots"}
</span>{" "}
between {formatTime(DAY_START)} and {formatTime(DAY_END)}.
</p>
</div>
</section>
);
}
npx shadcn@latest add @sevenui/component/number-field-12pnpm dlx shadcn@latest add @sevenui/component/number-field-12yarn dlx shadcn@latest add @sevenui/component/number-field-12bunx --bun shadcn@latest add @sevenui/component/number-field-12Step 2 of 3
How many people will use your workspace?
A rough guess is fine. You can add or remove seats any time.
- Shared workspaces
- Guest access
- 90-day version history
"use client";
import * as React from "react";
import { ArrowLeft, ArrowRight, Check } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
NumberField,
NumberFieldDecrement,
NumberFieldGroup,
NumberFieldIncrement,
NumberFieldInput,
} from "@/components/ui/number-field";
const presets = [1, 5, 15, 50];
const plans = [
{
name: "Solo",
upTo: 1,
perks: ["Unlimited projects", "7-day version history"],
},
{
name: "Team",
upTo: 20,
perks: ["Shared workspaces", "Guest access", "90-day version history"],
},
{
name: "Business",
upTo: Number.POSITIVE_INFINITY,
perks: ["SAML single sign-on", "Audit log", "Unlimited version history"],
},
];
export default function NumberField13() {
const [teamSize, setTeamSize] = React.useState(5);
const plan =
plans.find((candidate) => teamSize <= candidate.upTo) ?? plans[0];
return (
<section
aria-labelledby="number-field-13-title"
className="grid w-full max-w-sm gap-6 rounded-xl border bg-card p-6 text-card-foreground"
>
<div className="grid gap-3">
<div className="flex items-center gap-1.5" aria-hidden="true">
<span className="h-1 flex-1 rounded-full bg-primary" />
<span className="h-1 flex-1 rounded-full bg-primary" />
<span className="h-1 flex-1 rounded-full bg-muted" />
</div>
<p className="text-xs text-muted-foreground">Step 2 of 3</p>
<h3
id="number-field-13-title"
className="text-xl font-semibold text-balance"
>
How many people will use your workspace?
</h3>
<p className="text-sm text-muted-foreground">
A rough guess is fine. You can add or remove seats any time.
</p>
</div>
<div className="grid justify-items-center gap-3">
<NumberField
value={teamSize}
onValueChange={(value) => setTeamSize(value ?? 1)}
min={1}
max={500}
largeStep={10}
>
<NumberFieldGroup className="h-14 rounded-xl">
<NumberFieldDecrement className="w-14 [&_svg]:size-5" />
<NumberFieldInput
aria-label="Team size"
className="w-24 text-2xl font-semibold"
/>
<NumberFieldIncrement className="w-14 [&_svg]:size-5" />
</NumberFieldGroup>
</NumberField>
<fieldset className="grid grid-cols-2 gap-1.5 sm:flex sm:flex-wrap sm:justify-center">
<legend className="sr-only">Common team sizes</legend>
{presets.map((preset) => (
<Button
key={preset}
size="xs"
variant={teamSize === preset ? "secondary" : "ghost"}
aria-pressed={teamSize === preset}
onClick={() => setTeamSize(preset)}
>
{preset === 1 ? "Just me" : `${preset} people`}
</Button>
))}
</fieldset>
</div>
<div
className="grid gap-2 rounded-lg border border-dashed p-4"
aria-live="polite"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Suggested plan</span>
<Badge variant="secondary">{plan.name}</Badge>
</div>
<ul className="grid gap-1.5 text-sm text-muted-foreground">
{plan.perks.map((perk) => (
<li key={perk} className="flex items-center gap-2">
<Check aria-hidden="true" className="size-3.5 text-foreground" />
{perk}
</li>
))}
</ul>
</div>
<div className="flex justify-between gap-2">
<Button variant="ghost">
<ArrowLeft aria-hidden="true" data-icon="inline-start" />
Back
</Button>
<Button>
Continue
<ArrowRight aria-hidden="true" data-icon="inline-end" />
</Button>
</div>
</section>
);
}
npx shadcn@latest add @sevenui/component/number-field-13pnpm dlx shadcn@latest add @sevenui/component/number-field-13yarn dlx shadcn@latest add @sevenui/component/number-field-13bunx --bun shadcn@latest add @sevenui/component/number-field-13Export harbor-sunrise.heic
Original 4032 × 3024 px · 6.2 MB
Arrow keys step by 1, Shift + arrow by 100. Scroll over a focused field to adjust it.
JPEG · about 1.1 MB
"use client";
import * as React from "react";
import { Check, Link2, Link2Off, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
NumberField,
NumberFieldGroup,
NumberFieldInput,
} from "@/components/ui/number-field";
import { Separator } from "@/components/ui/separator";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
const ORIGINAL = { width: 4032, height: 3024 };
const SCALES = [25, 50, 75, 100];
// Rough JPEG estimate at 85% quality, in bytes per pixel.
const BYTES_PER_PIXEL = 0.35;
function formatBytes(bytes: number) {
if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
return `${Math.max(1, Math.round(bytes / 1000))} KB`;
}
function DimensionField({
id,
label,
value,
onChange,
}: {
id: string;
label: string;
value: number;
onChange: (value: number) => void;
}) {
return (
<NumberField
id={id}
value={value}
onValueChange={(next) => onChange(next ?? 1)}
min={1}
max={8192}
largeStep={100}
allowWheelScrub
className="min-w-0 flex-1 gap-1.5"
>
<Label htmlFor={id} className="text-xs text-muted-foreground">
{label}
</Label>
<NumberFieldGroup className="w-full">
<NumberFieldInput className="w-0 min-w-0 flex-1 pl-3 text-left" />
<span
aria-hidden="true"
className="flex items-center pr-3 text-xs text-muted-foreground"
>
px
</span>
</NumberFieldGroup>
</NumberField>
);
}
export default function NumberField14() {
const [width, setWidth] = React.useState(2016);
const [height, setHeight] = React.useState(1512);
const [locked, setLocked] = React.useState(true);
const [exportStatus, setExportStatus] = React.useState<
"idle" | "exporting" | "done"
>("idle");
const timers = React.useRef<ReturnType<typeof setTimeout>[]>([]);
React.useEffect(() => {
const pending = timers.current;
return () => {
for (const timer of pending) clearTimeout(timer);
};
}, []);
function exportImage() {
for (const timer of timers.current) clearTimeout(timer);
timers.current.length = 0;
setExportStatus("exporting");
timers.current.push(
setTimeout(() => setExportStatus("done"), 900),
setTimeout(() => setExportStatus("idle"), 2900),
);
}
const ratio = ORIGINAL.width / ORIGINAL.height;
const estimate = width * height * BYTES_PER_PIXEL;
const activeScale = SCALES.find(
(scale) =>
width === Math.round((ORIGINAL.width * scale) / 100) &&
height === Math.round((ORIGINAL.height * scale) / 100),
);
function updateWidth(next: number) {
setWidth(next);
if (locked) setHeight(Math.max(1, Math.round(next / ratio)));
}
function updateHeight(next: number) {
setHeight(next);
if (locked) setWidth(Math.max(1, Math.round(next * ratio)));
}
function applyScale(scale: number) {
setWidth(Math.round((ORIGINAL.width * scale) / 100));
setHeight(Math.round((ORIGINAL.height * scale) / 100));
}
function toggleLock() {
if (!locked) setHeight(Math.max(1, Math.round(width / ratio)));
setLocked(!locked);
}
return (
<section
aria-labelledby="number-field-14-title"
className="w-full max-w-sm overflow-hidden rounded-xl border bg-card text-card-foreground"
>
<div className="flex items-center gap-3 p-4">
<img
src="/placeholder.svg"
alt=""
className="size-12 shrink-0 rounded-md border bg-muted object-cover"
/>
<div className="min-w-0">
<h3
id="number-field-14-title"
className="truncate text-sm font-medium"
>
Export harbor-sunrise.heic
</h3>
<p className="text-xs text-muted-foreground tabular-nums">
Original {ORIGINAL.width} × {ORIGINAL.height} px · 6.2 MB
</p>
</div>
</div>
<Separator />
<div className="grid gap-4 p-4">
<div className="grid gap-2">
<span id="number-field-14-scale" className="text-sm font-medium">
Scale
</span>
<ToggleGroup
aria-labelledby="number-field-14-scale"
variant="outline"
size="sm"
spacing={0}
value={activeScale ? [String(activeScale)] : []}
onValueChange={(next) => {
if (next.length > 0) applyScale(Number(next[0]));
}}
className="w-full"
>
{SCALES.map((scale) => (
<ToggleGroupItem
key={scale}
value={String(scale)}
className="flex-1 tabular-nums"
>
{scale}%
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
<div className="flex items-end gap-2">
<DimensionField
id="number-field-14-width"
label="Width"
value={width}
onChange={updateWidth}
/>
<Button
variant={locked ? "secondary" : "ghost"}
size="icon"
className="mb-0.5"
aria-pressed={locked}
aria-label="Keep aspect ratio"
onClick={toggleLock}
>
{locked ? (
<Link2 aria-hidden="true" />
) : (
<Link2Off aria-hidden="true" />
)}
</Button>
<DimensionField
id="number-field-14-height"
label="Height"
value={height}
onChange={updateHeight}
/>
</div>
<p className="text-xs text-muted-foreground">
Arrow keys step by 1, Shift + arrow by 100. Scroll over a focused
field to adjust it.
</p>
</div>
<div className="flex items-center justify-between gap-3 border-t bg-muted/40 px-4 py-3">
<p className="text-xs text-muted-foreground" aria-live="polite">
JPEG · about{" "}
<span className="font-medium text-foreground tabular-nums">
{formatBytes(estimate)}
</span>
</p>
<Button
size="sm"
disabled={exportStatus === "exporting"}
onClick={exportImage}
>
{exportStatus === "exporting" ? (
<Loader2
aria-hidden="true"
data-icon="inline-start"
className="animate-spin"
/>
) : exportStatus === "done" ? (
<Check aria-hidden="true" data-icon="inline-start" />
) : null}
{exportStatus === "exporting"
? "Exporting…"
: exportStatus === "done"
? "Exported"
: "Export"}
</Button>
</div>
</section>
);
}
npx shadcn@latest add @sevenui/component/number-field-14pnpm dlx shadcn@latest add @sevenui/component/number-field-14yarn dlx shadcn@latest add @sevenui/component/number-field-14bunx --bun shadcn@latest add @sevenui/component/number-field-14