Sizes

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.

Examples

A field fills its place

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>
  );
}

Sized to the value

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>
  );
}

A row that holds still

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>
  );
}

One size for a place

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>
  );
}

Narrow places

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>
  );
}

A row of fields

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>
  );
}

When to use something else

Keyboard

The keys of Button, ButtonGroup, Input, Textarea, NumberInput, Switch, Select, MultiSelect, DatePicker, DateRangePicker, DateTimeRangePicker and Modal apply here.

API

ControlSizeProviderProps

NameTypeDefaultDescription
sizerequiredControlSize
"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)

childrenrequiredReactNode—The place the size holds for.

Shown in: One size for a place, A row of fields, Drawn as a field (SegmentedControl)

Types on this page

ControlSize

The two heights of a control: sm for a toolbar and dense forms, md otherwise.

type ControlSize = "sm" | "md";

Known limits

What umriss deliberately does not build, and why: ADR-0032.

Every page of @umriss-ui/core

Getting started

Customising

Layout

Typography

Actions

Forms

Choosing

Dates and times

Feedback

Overlays

Navigation

Data display

API index