# Sizes

> Package `@umriss-ui/core`, version 0.40.0. Demo page: <https://romanhaendler.github.io/umriss-ui/core/sizes/>. Every page in one line: [llms.txt](https://romanhaendler.github.io/umriss-ui/core/llms.txt); every page in full: [llms-full.txt](https://romanhaendler.github.io/umriss-ui/core/llms-full.txt).

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.

```ts
import { ControlSizeProvider } from "@umriss-ui/core";
```

Demo page: https://romanhaendler.github.io/umriss-ui/core/sizes/

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](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0041-a-fields-width-never-follows-what-it-shows.md)).

`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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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 bar with a search

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.

```tsx
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

- A width the layout decides - two columns, a field beside a button → Stack and Grid
- A whole table's density, rows and controls together → UmrissProvider

## Keyboard

The keys of [Button](https://romanhaendler.github.io/umriss-ui/core/button/#keyboard-button), [ButtonGroup](https://romanhaendler.github.io/umriss-ui/core/buttongroup/#keyboard-buttongroup), [Input](https://romanhaendler.github.io/umriss-ui/core/input/#keyboard-input), [Textarea](https://romanhaendler.github.io/umriss-ui/core/textarea/#keyboard-textarea), [NumberInput](https://romanhaendler.github.io/umriss-ui/core/numberinput/#keyboard-numberinput), [Switch](https://romanhaendler.github.io/umriss-ui/core/switch/#keyboard-switch), [Select](https://romanhaendler.github.io/umriss-ui/core/select/#keyboard-select), [MultiSelect](https://romanhaendler.github.io/umriss-ui/core/multiselect/#keyboard-multiselect), [DatePicker](https://romanhaendler.github.io/umriss-ui/core/datepicker/#keyboard-datepicker), [DateRangePicker](https://romanhaendler.github.io/umriss-ui/core/daterangepicker/#keyboard-daterangepicker), [DateTimeRangePicker](https://romanhaendler.github.io/umriss-ui/core/datetimerangepicker/#keyboard-datetimerangepicker) and [Modal](https://romanhaendler.github.io/umriss-ui/core/modal/#keyboard-modal) apply here.

## API

### `ControlSizeProviderProps`

| Prop | Type | Default | Description |
|---|---|---|---|
| `size` *required* | [`ControlSize`](https://romanhaendler.github.io/umriss-ui/core/sizes/#type-ControlSize)<br>`"sm" \| "md"` | — | The size of every control inside that does not say its own.<br>Shown in: [One size for a place](https://romanhaendler.github.io/umriss-ui/core/sizes/#one-size-for-a-place), [A row of fields](https://romanhaendler.github.io/umriss-ui/core/sizes/#a-row-of-fields), [Drawn as a field (SegmentedControl)](https://romanhaendler.github.io/umriss-ui/core/segmentedcontrol/#drawn-as-a-field) |
| `children` *required* | `ReactNode` | — | The place the size holds for.<br>Shown in: [One size for a place](https://romanhaendler.github.io/umriss-ui/core/sizes/#one-size-for-a-place), [A row of fields](https://romanhaendler.github.io/umriss-ui/core/sizes/#a-row-of-fields), [Drawn as a field (SegmentedControl)](https://romanhaendler.github.io/umriss-ui/core/segmentedcontrol/#drawn-as-a-field) |

### Types on this page

#### `ControlSize`

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

```ts
type ControlSize = "sm" | "md";
```

## Known limits

- No width in pixels: a width in characters scales with the type, under a finger too; a place's width is the layout's.
- `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.
- A field does not grow to fill a row: give it room with the row's own layout (`flex: 1` on its FormField, or a grid).
- A place that sizes itself by its content and has no width to stop at - an `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](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/).
