How wide and how tall a field is (width, height, field size). A field fills its place, holds still in a row whatever it shows, takes the width of its value with chars, and takes one size from the place it stands in.
import { ControlSizeProvider } from "@umriss-ui/core";
Width. Say nothing, and a field fills the place that gives it a width - a form's column, a grid's cell, a dialog. Where the place asks the field instead - a row, a toolbar, a column that lines up at the start - the field is its natural width: 16 characters of its own type for a text field, select and combobox, 20 for a multiselect (two chips and the counter), 10 for a number, 40 for a textarea, the longest value of its formats for a date picker. What it shows never moves it: not its value, not its options, not its chips (ADR-0041).
chars gives the width in characters - the room for the value, with the field's padding, glyph, cross and steppers added. Given, the field is that wide in every place. Either way it is never wider than its place, and what does not fit ends in an ellipsis.
Height. Every control with two heights takes size, sm or md. ControlSizeProvider sets it for a place - a toolbar, a dense form; a control's own size wins, and a popover, dialog or tooltip opened from inside starts without it.
Say nothing about width, and a field fills the place it is given - the card's column, a grid's cell. The form below has no width in it but the card's.
import { Card, CardBody, FormField, Grid, Input, Select, Stack, Textarea } from "@umriss-ui/core";
export default function FillThePlace() {
return (
<Card style={{ maxWidth: 560 }}>
<CardBody>
<Stack gap={4}>
<Grid columns={2} gap={4}>
<FormField label="First name">
<Input autoComplete="given-name" />
</FormField>
<FormField label="Last name">
<Input autoComplete="family-name" />
</FormField>
</Grid>
<FormField label="Delivery service">
<Select defaultValue="standard">
<option value="standard">Standard, two to three working days</option>
<option value="express">Express, next working day before noon</option>
</Select>
</FormField>
<FormField label="Note for the driver" hint="Where the parcel may be left if nobody is home.">
<Textarea rows={2} />
</FormField>
</Stack>
</CardBody>
</Card>
);
}
Give chars the length of the value, and the field is that wide - a postcode five characters, an IBAN twenty-seven - in characters of its own type, with its padding, glyph and steppers added. In a column that lines its fields up at the start, every field shows its own width; a date picker without chars is as wide as its longest date. Type a short postcode: the message wraps under its field and moves nothing.
import { useState } from "react";
import { DatePicker, FormField, Input, NumberInput, Stack } from "@umriss-ui/core";
export default function SizedToTheValue() {
const [postcode, setPostcode] = useState("20457");
const [amount, setAmount] = useState<number | null>(1280.5);
const [due, setDue] = useState<Date | null>(new Date(2026, 2, 31));
const wrong = postcode !== "" && !/^\d{5}$/.test(postcode);
return (
<Stack gap={4} align="start">
<Stack direction="row" gap={3} wrap>
<FormField label="Postcode" error={wrong ? "Five digits." : undefined}>
<Input
chars={5}
inputMode="numeric"
autoComplete="postal-code"
value={postcode}
onChange={(event) => setPostcode(event.target.value)}
/>
</FormField>
<FormField label="City">
<Input autoComplete="address-level2" defaultValue="Hamburg" />
</FormField>
</Stack>
<FormField label="IBAN">
<Input chars={27} defaultValue="DE89 3704 0044 0532 0130 00" />
</FormField>
<FormField label="Amount">
<NumberInput chars={9} value={amount} onChange={setAmount} decimals={2} suffix="€" />
</FormField>
<FormField label="Due">
<DatePicker value={due} onChange={setDue} clearable />
</FormField>
</Stack>
);
}
In a row a field is its natural width - a count of characters of its own type - and never what it shows. Choose carriers, clear them one by one or all at once, pick the long status: nothing in the row moves. The chips that do not fit stand as "+N", and the figures below are the fields' widths, measured live.
import { useLayoutEffect, useRef, useState } from "react";
import type { RefObject } from "react";
import { DateRangePicker, Input, MultiSelect, Select, Stack, Switch, Text } from "@umriss-ui/core";
import type { DateRange } from "@umriss-ui/core";
const CARRIERS = ["DHL", "UPS", "DPD", "GLS", "Hermes", "FedEx", "TNT", "DB Schenker", "Kühne + Nagel", "Dachser"].map(
(name) => ({ value: name, label: name }),
);
/** The rendered width of an element, kept up to date. */
function useWidth(ref: RefObject<HTMLElement | null>) {
const [width, setWidth] = useState(0);
useLayoutEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new ResizeObserver(() => setWidth(Math.round(element.getBoundingClientRect().width)));
observer.observe(element);
return () => observer.disconnect();
}, [ref]);
return width;
}
export default function ARowThatHoldsStill() {
const [search, setSearch] = useState("");
const [carriers, setCarriers] = useState<string[]>(["DHL", "UPS", "GLS"]);
const [period, setPeriod] = useState<DateRange | null>(null);
const carriersRef = useRef<HTMLDivElement>(null);
const statusRef = useRef<HTMLSelectElement>(null);
const carriersWidth = useWidth(carriersRef);
const statusWidth = useWidth(statusRef);
return (
<Stack gap={3}>
<Stack direction="row" gap={2} align="center" wrap>
<Input
aria-label="Search shipments"
placeholder="Search shipments"
value={search}
onChange={(event) => setSearch(event.target.value)}
clearable
onClear={() => setSearch("")}
/>
<Select ref={statusRef} aria-label="Status" defaultValue="">
<option value="">Every status</option>
<option>Out for delivery</option>
<option>Held at customs until the papers of origin are checked</option>
</Select>
<MultiSelect ref={carriersRef} aria-label="Carriers" placeholder="Every carrier" options={CARRIERS} value={carriers} onChange={setCarriers} clearable />
<DateRangePicker aria-label="Shipped" value={period} onChange={setPeriod} clearable />
<Switch label="Late only" />
</Stack>
<Text size="xs" tone="muted" mono>
carriers: {carriers.length} chosen, {carriersWidth} px wide · status: {statusWidth} px wide
</Text>
</Stack>
);
}
Wrap a dense place in ControlSizeProvider size="sm", and every control in it is small without a word - fields, buttons, groups. A control's own size still wins, and a dialog opened from inside keeps a dialog's controls.
import { useState } from "react";
import {
Button,
ButtonGroup,
ControlSizeProvider,
DatePicker,
Input,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
Select,
Stack,
} from "@umriss-ui/core";
export default function OneSizeForAPlace() {
const [day, setDay] = useState<Date | null>(new Date(2026, 2, 17));
const [open, setOpen] = useState(false);
return (
<ControlSizeProvider size="sm">
<Stack direction="row" gap={2} align="center" wrap>
<Input aria-label="Search tickets" placeholder="Search tickets" />
<Select aria-label="Team" defaultValue="all">
<option value="all">Every team</option>
<option value="billing">Billing</option>
<option value="accounts">Accounts</option>
</Select>
<DatePicker aria-label="Opened on" value={day} onChange={setDay} />
<ButtonGroup aria-label="State">
<Button>Open</Button>
<Button>Waiting</Button>
<Button>Solved</Button>
</ButtonGroup>
<Button variant="primary" onClick={() => setOpen(true)}>
New ticket
</Button>
</Stack>
<Modal open={open} onClose={() => setOpen(false)}>
<ModalHeader title="New ticket" description="Opened from the small bar, it keeps a dialog's controls." />
<ModalBody>
<Input aria-label="Subject" placeholder="Subject" />
</ModalBody>
<ModalFooter>
<Button onClick={() => setOpen(false)}>Cancel</Button>
<Button variant="primary" onClick={() => setOpen(false)}>
Save
</Button>
</ModalFooter>
</Modal>
</ControlSizeProvider>
);
}
A field is never wider than its place - not with chars={34}, not with the longest range of instants. In a narrow card it shrinks with it, and what no longer fits ends in an ellipsis instead of pushing the page sideways.
import { useState } from "react";
import { Card, CardBody, CardHeader, DateTimeRangePicker, FormField, Input, MultiSelect, Stack } from "@umriss-ui/core";
import type { DateRange } from "@umriss-ui/core";
const TEAM = ["Maya Lindgren", "Luis Moreno", "Ada Okafor", "Jun Park"].map((name) => ({ value: name, label: name }));
export default function NarrowPlaces() {
const [slot, setSlot] = useState<DateRange | null>({
from: new Date(2026, 2, 16, 6, 0),
to: new Date(2026, 2, 20, 22, 0),
});
const [team, setTeam] = useState<string[]>(["Maya Lindgren", "Luis Moreno"]);
return (
<Card style={{ maxWidth: 280 }}>
<CardHeader title="Maintenance window" />
<CardBody>
<Stack gap={3}>
<FormField label="IBAN for the invoice">
<Input chars={34} defaultValue="GB33 BUKB 2020 1555 5555 55" />
</FormField>
<FormField label="Window">
<DateTimeRangePicker value={slot} onChange={setSlot} />
</FormField>
<FormField label="On call">
<MultiSelect options={TEAM} value={team} onChange={setTeam} />
</FormField>
</Stack>
</CardBody>
</Card>
);
}
Fields side by side share one line: their labels on one line, their boxes on one height, their words on the line the select's value stands on. A segmented control is as tall as a field; a horizontal radio group is as tall as a control, and so are a checkbox and a switch in a field, so their words stand on that line too - at either size.
import { Checkbox, ControlSizeProvider, FormField, RadioGroup, SegmentedControl, Select, Stack, Switch } from "@umriss-ui/core";
import type { ControlSize } from "@umriss-ui/core";
function Row({ size }: { size: ControlSize }) {
return (
<Stack role="group" aria-label={size === "sm" ? "Small" : "Medium"} direction="row" gap={3} wrap>
<FormField label="Caster">
<Select defaultValue="" chars={12}>
<option value="">Every caster</option>
<option>Caster 1</option>
<option>Caster 2</option>
</Select>
</FormField>
<FormField label="Strand">
<Select defaultValue="" chars={12}>
<option value="">Every strand</option>
<option>Strand 1</option>
<option>Strand 2</option>
</Select>
</FormField>
<FormField label="State of the data">
<RadioGroup
orientation="horizontal"
defaultValue="raw"
options={[
{ value: "raw", label: "Raw" },
{ value: "cleaned", label: "Cleaned" },
]}
/>
</FormField>
<FormField label="Values">
<SegmentedControl
defaultValue="measured"
options={[
{ value: "measured", label: "Measured" },
{ value: "forecast", label: "Forecast" },
]}
/>
</FormField>
<FormField label="Archive">
<Checkbox label="Show archived" />
</FormField>
<FormField label="Updates">
<Switch label="Live" defaultChecked />
</FormField>
</Stack>
);
}
export default function ARowOfFields() {
return (
<Stack gap={5}>
<Row size="md" />
<ControlSizeProvider size="sm">
<Row size="sm" />
</ControlSizeProvider>
</Stack>
);
}
A field does not grow to fill a row; a bar of one's own says which one takes the rest. In a grid, a field fills its cell: the search is the "fill" column, the selects beside it keep a width of their own, and nothing is styled by hand.
import { useState } from "react";
import { Grid, Input, Select, Stack, Text } from "@umriss-ui/core";
const ROWS = [
{ name: "Invoice 1042", status: "Open", region: "North" },
{ name: "Invoice 1043", status: "Paid", region: "South" },
{ name: "Invoice 1044", status: "Open", region: "South" },
{ name: "Invoice 1045", status: "Overdue", region: "North" },
];
export default function ABarWithASearch() {
const [search, setSearch] = useState("");
const [status, setStatus] = useState("");
const [region, setRegion] = useState("");
const shown = ROWS.filter(
(row) =>
row.name.toLowerCase().includes(search.trim().toLowerCase()) &&
(!status || row.status === status) &&
(!region || row.region === region),
);
return (
<Stack gap={3}>
<Grid columns={["fill", 180, 180]} gap={3}>
<Input
type="search"
aria-label="Search"
placeholder="Search invoices"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
<Select aria-label="Status" value={status} onChange={(event) => setStatus(event.target.value)}>
<option value="">Any status</option>
<option>Open</option>
<option>Paid</option>
<option>Overdue</option>
</Select>
<Select aria-label="Region" value={region} onChange={(event) => setRegion(event.target.value)}>
<option value="">Any region</option>
<option>North</option>
<option>South</option>
</Select>
</Grid>
<Text size="sm" tone="secondary">
{shown.length} of {ROWS.length} invoices: {shown.map((row) => row.name).join(", ") || "none"}
</Text>
</Stack>
);
}
The keys of Button, ButtonGroup, Input, Textarea, NumberInput, Switch, Select, MultiSelect, DatePicker, DateRangePicker, DateTimeRangePicker and Modal apply here.
ControlSizeProviderProps| Name | Type | Default | Description |
|---|---|---|---|
sizerequired | ControlSize"sm" | "md" | — | The size of every control inside that does not say its own. Shown in: One size for a place, A row of fields, Drawn as a field (SegmentedControl) |
childrenrequired | ReactNode | — | The place the size holds for. Shown in: One size for a place, A row of fields, Drawn as a field (SegmentedControl) |
ControlSizeThe two heights of a control: sm for a toolbar and dense forms, md
otherwise.
type ControlSize = "sm" | "md";Slider and FileInput take no chars - they show no text to count; their width is the place's, or their natural width where the place asks.flex: 1 on its FormField, or a grid).auto grid track, an inline block, a row that does not wrap - gives a field at least its natural width, as it gives a native input its own; on a narrow screen let such a row wrap, or size the track with minmax(0, auto).What umriss deliberately does not build, and why: ADR-0032.