# SpaceUI — full API reference
> A React 19 component library and design system for dark interfaces. Zero runtime dependencies, precompiled CSS, and a role-based design-token system. Install it, import one stylesheet, and the components are finished: there is no theme to configure, no provider to mount and no build-time plugin to register.
Version 1.11.0. Generated from the library source, so it describes
exactly the version named here. Index: https://space-components.vercel.app/llms.txt
## How to read this
Each component lists its own props, then the props it inherits from a DOM
attribute set, then any data shape a caller has to build, then the tokens it
accepts. A prop marked required has no default; everything else shows the
default the component itself applies.
1. **Import the stylesheet once, at the application root.** Every component's CSS ships precompiled in one file. There is no per-component import and no Sass for consumers.
2. **Do not add a provider, context or theme wrapper.** Components read design tokens straight from CSS custom properties on the document. Nothing needs to be mounted above them.
3. **The library is dark-only, by design.** The tokens can be re-pointed, but the components are drawn as lit glass over a dark ground, so a light palette misrepresents every surface. There is no supported light theme; do not build one.
4. **Style with tokens, never with raw lengths or colours.** Spacing comes from the step scale, control heights from the ladder, colour from a named role. A raw px value puts the layout off the 8pt grid the whole library shares.
5. **Override a component token on a class, not with an inline style.** An inline style sets the property at the highest specificity there is, so it defeats every media and container query the consumer later writes. Set the token on a class instead.
6. **Component tokens are named --sp-[component]-[modifier]-[type].** So `--sp-carousel-gap-size`, `--sp-checkbox-label-gap-size`. Every token a component accepts is listed with that component below.
7. **Boolean props default to false, so the bare attribute is the idiom.** Write ` `, not `fullWidth={true}`. The one deliberate exception is `animated`, which defaults to true.
8. **Selection components are controlled through onValueChange.** Select, Slider, RadioGroup, CheckboxGroup and Tabs take a `value` and an `onValueChange`. Native text inputs (TextField, TextArea) keep React's own `value` and `onChange`.
9. **Keyboard support and ARIA are already inside each component.** Roving tabindex, arrow-key navigation, Escape handling, aria-checked, aria-expanded and aria-current are implemented. Do not re-declare them from outside. Do pass `aria-label` wherever a control has no visible label.
10. **Nothing touches the DOM at import time.** Components that measure do it inside effects, so the entry point imports cleanly in a server build. Components that need the top layer use the platform popover API, so no portal target has to be provided.
---
## Autocomplete
A text field that offers matching rows as you type. It holds no data of its own. Give it `options` and it renders them, highlights one, moves that highlight with the arrow keys, and calls `onSelect` when a row is chosen — however those options were produced. The list is a popover rather than a block in the flow: results that appear mid-keystroke and push the page down under the cursor are disorienting, and whatever is being searched usually sits directly below the field.
```tsx
import { Autocomplete } from "@inkorange/space-ui";
```
### Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `value` | `string` | required | The text in the field. Fully controlled. |
| `onValueChange` | `(value: string) => void` | required | Called on every keystroke. |
| `options` | `AutocompleteOption[]` | required | The candidate rows, in the order you want them shown. They are narrowed against what has been typed unless `preFiltered` is set; the order you give is always preserved, so ranking stays yours. Nothing is offered until something has been typed. Past that, an empty array with no `loading` and no `emptyMessage` still shows no panel, which is how you hold it shut below a minimum query length. |
| `onSelect` | `(value: string, option: AutocompleteOption) => void` | required | Called when a row is chosen by click or Enter. |
| `icon` | `ReactNode` | — | Renders a leading glyph inside the field — a magnifier, usually. |
| `caseSensitive` | `boolean` | `false` | Match exactly as typed, so `trappist` no longer finds `TRAPPIST`. Default off, which is what nearly every search wants. |
| `preFiltered` | `boolean` | `false` | The rows were already matched — by a server, a fuzzy search, a synonym table — so render them as given. Without this a substring test here would silently drop rows a smarter match upstream had found. |
| `loading` | `boolean` | `false` | Replaces the list with a waiting message while results are on their way. |
| `loadingMessage` | `ReactNode` | `"Loading…"` | What that waiting message says. Name the thing being fetched — "Loading the catalogue…" tells a reader more than a spinner's worth of nothing. Default "Loading…". |
| `emptyMessage` | `ReactNode` | — | Shown when there are no options and nothing is loading. Leave it out to show no panel at all rather than an empty one. |
| `footer` | `ReactNode` | — | A line under the list, outside its scroll area — "showing 50 of 2,700", a hint, a keyboard legend. |
| `animated` | `boolean` | `true` | Ambient motion on the field's rim. The glass skin is always applied. Default true. |
| `className` | `string` | — | Merged onto the wrapper, which is what you size. |
| `style` | `React.CSSProperties` | — | Applied to the wrapper — set width here. |
Also accepts every attribute of `Omit< React.InputHTMLAttributes, "value" | "onChange" | "onSelect" | "className" | "style" >` — 303 inherited props, of which the ones usually reached for are `onClick`, `disabled`, `placeholder`, `type`, `required`, `readOnly`, `name`, `onFocus`, `onBlur`, `onKeyDown`, `maxLength`, `id`, `aria-label`, `tabIndex`, `title`, `autoFocus`, `defaultValue`.
### AutocompleteOption
The shape behind `options`, `onSelect`.
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `value` | `string` | required | Handed back to `onSelect` when this row is chosen. |
| `label` | `ReactNode` | required | The row's main text. |
| `meta` | `ReactNode` | — | Optional secondary text, set against the trailing edge — a category, a score, a date. Kept out of `label` so it stays visually subordinate and does not affect which row a reader scans for. |
| `disabled` | `boolean` | — | Shown, but skipped by the keyboard and unselectable. |
| `search` | `string` | — | The text the typed query is matched against, when `label` is not a plain string. Falls back to `label` if it is one, and to `value` otherwise — and `value` is usually a slug, which is rarely what someone is typing. |
### Tokens
Set these on a class that wraps or targets the component.
| Token | Default | Notes |
| --- | --- | --- |
| `--sp-autocomplete-option-highlight-color` | `rgb(150 190 255 / 0.14)` | |
| `--sp-autocomplete-panel-max-height` | `320px` | |
---
## Badge
A compact status label. Colour is the message, so choose it for meaning: the semantic roles say safe, pending or destructive, while the categorical hues only tell one kind of thing from another and carry no judgement. Single-line by default, since a badge that wraps stops reading as a badge. Opt into `wrap` for free text you do not control.
```tsx
import { Badge } from "@inkorange/space-ui";
```
### Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `color` | `BadgeColor` | `"primary"` | Semantic role or categorical hue. Roles carry meaning — pick one that matches the state, not the look you want. |
| `variant` | `"soft" \| "solid"` | `"soft"` | `soft` is a tinted wash for dense lists; `solid` is a filled chip for a badge that must win attention. |
| `size` | `"1" \| "2"` | `"1"` | 1 is the default chip; 2 is for standalone use away from body text. |
| `wrap` | `boolean` | — | Badges are single-line by default: one that wraps stops reading as a badge. Opt into wrapping for free text you do not control — anything a reader named rather than you — so it cannot overflow what contains it. |
| `children` | `ReactNode` | — | Label text. Keep it short — a badge that wraps stops reading as one. |
Also accepts every attribute of `SpacingProps` and `Omit, "color">` — 281 inherited props, of which the ones usually reached for are `onClick`, `onChange`, `onFocus`, `onBlur`, `onKeyDown`, `id`, `aria-label`, `tabIndex`, `title`, `autoFocus`, `defaultValue`.
---
## BarList
A ranked list of values as bars: a label, a track, a filled bar, a figure. For results read against each other — a poll, the top ten by some measure, a breakdown of a total. It is the form to reach for when the category names are words rather than dates: they get a column of their own and stay readable, where a vertical bar chart would turn them on their side. No axis and no grid, because there is nothing to measure against but the other rows: every bar is drawn against the longest one, or against `max` when the share of a known total is the point.
```tsx
import { BarList } from "@inkorange/space-ui";
```
### Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `items` | `BarListItem[]` | required | The rows, in the order they should appear. Sort before passing: the order is the caller's statement about the data, not this component's. |
| `max` | `number` | — | What a full track means. Defaults to the largest value, which makes the rows relative to each other. Pass the total to make them shares of it. |
| `showPercent` | `boolean` | `false` | Show each row's share as a percentage beside its value. |
| `formatValue` | `(value: number) => string` | — | How the value is written. |
| `colorful` | `boolean` | `false` | Give every row its own colour from the series palette, rather than one colour for the set. |
| `animated` | `boolean` | `true` | Fill the bars on first render, one row after the next, the way results arrive. Default true; a reader who asked for less motion gets the bars already filled either way. |
| `onItemClick` | `(item: BarListItem, index: number) => void` | — | Called when a row is chosen. Rows become buttons when this is given, and stay plain text when it is not — nothing looks clickable unless it is. |
| `aria-label` | `string` | — | Names the set for assistive tech: "Poll results", say. |
Also accepts every attribute of `Omit, "onSelect">` — 276 inherited props, of which the ones usually reached for are `onClick`, `onChange`, `onFocus`, `onBlur`, `onKeyDown`, `id`, `tabIndex`, `title`, `autoFocus`, `defaultValue`.
### BarListItem
The shape behind `items`, `onItemClick`.
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `label` | `string` | required | The row's name, at the left. |
| `value` | `number` | required | How long the bar is, against the largest value or an explicit `max`. |
| `color` | `string` | — | Overrides this row's colour. One colour for the set reads as a ranking; a colour per row reads as categories. |
### Tokens
Set these on a class that wraps or targets the component.
| Token | Default | Notes |
| --- | --- | --- |
| `--sp-bar-list-label-width` | `96px` | |
| `--sp-bar-list-track-height` | `8px` | |
---
## Box
A plain div that understands the spacing scale. The escape hatch for when you need margin or padding on the grid but no layout behaviour with it. Renders nothing of its own — no class, no styling — so it never interferes with whatever you put inside.
```tsx
import { Box } from "@inkorange/space-ui";
```
### Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `children` | `ReactNode` | — | Anything. Box adds no styling of its own. |
Also accepts every attribute of `SpacingProps` and `React.HTMLAttributes` — 282 inherited props, of which the ones usually reached for are `onClick`, `onChange`, `onFocus`, `onBlur`, `onKeyDown`, `id`, `aria-label`, `tabIndex`, `title`, `autoFocus`, `defaultValue`.
---
## Button
The thing a reader presses to make something happen. A glass capsule whose rim glows like a planet's sunlit limb, with stars twinkling inside it and a glint that sweeps across it now and then. It renders a plain `` and forwards its ref, so it drops into anything that needs to borrow a trigger — a dialog, a menu, a router link's render prop — without a wrapper in between.
```tsx
import { Button } from "@inkorange/space-ui";
```
### Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `iconOnly` | `boolean` | — | Circular, icon-only sizing (e.g. the share glyph). |
| `ember` | `boolean` | — | Warm red-dwarf rim for destructive-ish actions (e.g. Clear). |
| `size` | `"sm" \| "md" \| "lg" \| "micro"` | `"md"` | md = default. lg = hero CTAs. sm = compact dialog actions. micro = tiny steppers (no stars/glint). |
| `fullWidth` | `boolean` | — | Stretch to the container width (sticky footers). |
| `animated` | `boolean` | `true` | Ambient motion: the orbiting rim, the glint sweep, and the twinkling stars. Set false for dense UI or long lists where the movement distracts. The glass look itself is not optional. Default true. |
Also accepts every attribute of `React.ButtonHTMLAttributes` — 288 inherited props, of which the ones usually reached for are `onClick`, `onChange`, `disabled`, `value`, `type`, `name`, `onFocus`, `onBlur`, `onKeyDown`, `id`, `aria-label`, `tabIndex`, `title`, `autoFocus`, `defaultValue`.
---
## Card
A translucent panel for grouping related content, one step above the page surface. Its 12px inset is a deliberate exception to the 8pt grid, kept for parity with the app this was extracted from. Pass `image` for a media card. The image is framed by the card rather than being an ordinary child, so every card in a row reserves the same box before its image loads and none of them reflow when it does.
```tsx
import { Card } from "@inkorange/space-ui";
```
### Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `image` | `ReactNode` | — | A media element to sit at the top of the card, full-bleed to its edges. Pass whatever your framework renders — a plain ` `, a `next/image`, a `` — and the card frames it: cropped to fill, top corners matched to its own radius, and a reserved box so the layout does not jump when it loads. The reserved box is `--sp-card-image-ratio` (default `16 / 10`). Set it per card for a different crop. |
| `children` | `ReactNode` | — | The card's contents. |
Also accepts every attribute of `SpacingProps` and `React.HTMLAttributes` — 282 inherited props, of which the ones usually reached for are `onClick`, `onChange`, `onFocus`, `onBlur`, `onKeyDown`, `id`, `aria-label`, `tabIndex`, `title`, `autoFocus`, `defaultValue`.
### Tokens
Set these on a class that wraps or targets the component.
| Token | Default | Notes |
| --- | --- | --- |
| `--sp-card-image-ratio` | `16 / 10` | |
---
## Carousel
A row of slides that scrolls sideways, with arrows and optional dots. The browser does the heavy lifting. The row is a native horizontal scroller with CSS scroll snapping, so a flick on a phone uses the platform's own momentum and lands on a slide, and nothing runs per frame while it moves. Slide widths come from a CSS calculation on the view count, so a breakpoint can change how many fit without React knowing. A mouse can drag the row too. It follows the pointer, then glides to the next stop in the direction it was dragged; a drag never clicks a link or button in the slide it ends on. ## Responsive view count The number of slides in view is a design token, not state. Vary it by screen width in CSS; never by swapping `perView` from a resize listener or `matchMedia`, which re-renders, and can disagree with the server render. 1. Pass `perView` for the widest layout. 2. Set `--sp-carousel-view-count` on a class (or any ancestor) in media or container queries for the other widths. Fractions are fine. 3. Nothing else. Widths are computed in CSS, and the arrows, dots and page size are measured from the rendered row whenever it resizes. Precedence: `--sp-carousel-view-count` from a stylesheet, then `perView`, then 1. The prop is passed as a private default (`--_view-count-default`), not as the public token, precisely so a stylesheet can override it. Setting the token on `:root` overrides `perView` on every carousel in the app; scope it to a class or section instead. `--sp-carousel-gap-size` can be varied the same way.
```tsx
import { Carousel } from "@inkorange/space-ui";
```
### Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `children` | `ReactNode` | — | The slides, one child each. |
| `perView` | `number` | `1` | How many slides fit the viewport. Fractions show a partial slide at the trailing edge — `2.5` is two whole slides and half a third, a cue that there is more to swipe to. Default 1. This is the count wherever no stylesheet overrides it. To vary it by screen width, do not switch this prop from JavaScript: set the `--sp-carousel-view-count` token in media queries instead, which always wins over the prop. See "Responsive view count" on . |
| `step` | `"item" \| "page"` | `"item"` | What an arrow moves by, and what a dot stands for. `item` moves one slide and shows a dot per stopping point; `page` moves by the number of whole slides in view, snaps only at page boundaries, and shows a dot per page. Default `item`. |
| `showPagination` | `boolean` | `false` | Show the dots beneath the slides. |
| `animated` | `boolean` | `true` | Ambient motion on the arrow buttons' rims. Default true. |
| `aria-label` | `string` | — | Names the carousel for assistive tech. Say what it holds — "Featured planets" — rather than that it is a carousel, which is announced already. Default "Carousel". |
Also accepts every attribute of `Omit, "children">` — 276 inherited props, of which the ones usually reached for are `onClick`, `onChange`, `onFocus`, `onBlur`, `onKeyDown`, `id`, `tabIndex`, `title`, `autoFocus`, `defaultValue`.
### Tokens
Set these on a class that wraps or targets the component.
| Token | Default | Notes |
| --- | --- | --- |
| `--sp-carousel-gap-size` | `16px` | |
| `--sp-carousel-view-count` | `var(--_view-count-default, 1` | |
---
## Chart
A chart of one or more datasets over a shared set of categories — as bars, a line, or a filled area. Drawn as plain SVG from the library's own arithmetic: no charting dependency, and every colour, size and radius is a token, so a chart is the same material as the rest of the page. The series palette is an order rather than a pool. The first series is always the first colour, so hiding one never repaints the others. What it does without being asked: a legend once there is more than one series, a crosshair reading every series at the category under the pointer, and a table of the same numbers for screen readers. What it refuses to do: a second y axis. Two measures on different scales belong in two charts — one pair of axes can be drawn to tell any story you like.
```tsx
import { Chart } from "@inkorange/space-ui";
```
### Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `type` | `"bar" \| "line" \| "area"` | required | How to draw it. The same data can be any of the three: bars for comparing amounts, a line for a trend, an area for a trend whose total matters. Switching is a prop, so a chart can offer the choice. |
| `categories` | `string[]` | required | The x axis, in order. Every series' `data` lines up with this by position, so they must be the same length. |
| `series` | `ChartSeries[]` | required | One entry per dataset. |
| `height` | `number` | `240` | How tall the plot is, in pixels. The width comes from the container. Default 240. |
| `stacked` | `boolean` | `false` | Stack the series instead of setting them side by side (bar) or overlaying them (area). For parts of a whole, where the total is the point. |
| `legend` | `boolean` | — | Show the legend. Defaults to true for more than one series, which is when colour alone would otherwise be carrying the identity. |
| `grid` | `boolean` | `true` | Draw the horizontal grid lines. Default true. |
| `formatValue` | `(value: number) => string` | — | How a value is written, in labels and the tooltip. |
| `tickCount` | `number` | `5` | Roughly how many labelled ticks the y axis gets. Default 5. |
| `animated` | `boolean` | `true` | The lit finish this library gives everything else: marks that catch the light, and a first draw where bars rise from the axis and a line draws itself on. Default true; a reader who asked for less motion gets the finish without the movement either way. |
| `partialFrom` | `number` | — | The category index from which the data is still arriving — usually `categories.length - 1`, today's bar, which will keep growing until the day ends. Those bars are drawn hatched rather than solid, and the tooltip and the table say "so far", so a half-counted day is never read as a collapse. Bars only: a line says the same thing with its own last segment, which is a separate job. |
| `onPointClick` | `(point: ChartPoint) => void` | — | Called when a bar or point is chosen, with what it stands for. Keyboard activation reports the same thing a click does. |
| `aria-label` | `string` | — | Says what the chart shows, for a reader who cannot see it. The figures themselves are always available in the table this renders for screen readers, so this wants the point, not the numbers. |
Also accepts every attribute of `Omit, "onClick">` — 276 inherited props, of which the ones usually reached for are `onChange`, `onFocus`, `onBlur`, `onKeyDown`, `id`, `tabIndex`, `title`, `autoFocus`, `defaultValue`.
### ChartSeries
The shape behind `series`.
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `name` | `string` | required | Named in the legend and the tooltip, so say what the numbers are. |
| `data` | `(number \| null)[]` | required | One value per category, in the same order. `null` is a gap — a month with no reading — and is drawn as one rather than as zero. |
| `color` | `string` | — | Overrides this series' colour. Leave it alone unless the series has a meaning of its own that a reader already associates with a colour. |
### ChartPoint
The shape behind `onPointClick`.
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `series` | `string` | required | The series the point belongs to. |
| `seriesIndex` | `number` | required | |
| `category` | `string` | required | The category beneath it. |
| `categoryIndex` | `number` | required | |
| `value` | `number` | required | |
### Tokens
Set these on a class that wraps or targets the component.
| Token | Default | Notes |
| --- | --- | --- |
| `--sp-chart-axis-color` | `#d6ebfd33` | |
| `--sp-chart-grid-color` | `#d6ebfd1a` | |
| `--sp-chart-label-color` | `#9aa3ad` | |
| `--sp-chart-line-width` | `2px` | |
| `--sp-chart-mark-radius-size` | `4px` | |
| `--sp-chart-series-1-color` | `#3d8ff5` | |
| `--sp-chart-series-2-color` | `#d56a9e` | |
| `--sp-chart-series-3-color` | `#1f9e6b` | |
| `--sp-chart-series-4-color` | `#e2701f` | |
| `--sp-chart-series-5-color` | `#8b6df0` | |
| `--sp-chart-series-6-color` | `#a89400` | |
| `--sp-chart-series-7-color` | `#1f93ad` | |
| `--sp-chart-series-8-color` | `#e0594f` | |
| `--sp-chart-track-color` | `#d6ebfd14` | |
---
## Checkbox
A single yes/no choice: accepting terms, turning one setting on. For a set of options where several can be chosen, wrap `CheckboxGroup` around `CheckboxGroup.Item`s and hold one array instead of a boolean each.
```tsx
import { Checkbox } from "@inkorange/space-ui";
```
### Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `checked` | `boolean` | required | Whether it is ticked. Fully controlled. |
| `onCheckedChange` | `(checked: boolean) => void` | required | Called with the new state. |
| `indeterminate` | `boolean` | — | Partly chosen: draws a dash instead of a tick and announces as "mixed". For a "select all" whose children are only some chosen. The box still reports `checked` when clicked, so decide there what a click means. |
| `disabled` | `boolean` | — | Disables it. |
| `aria-label` | `string` | — | Names it for assistive tech when there are no children to do so. |
| `className` | `string` | — | Merged onto the label. |
| `children` | `ReactNode` | — | The label. Part of the click target, not merely beside it. |
### Tokens
Set these on a class that wraps or targets the component.
| Token | Default | Notes |
| --- | --- | --- |
| `--sp-checkbox-box-size` | `16px` | |
| `--sp-checkbox-glow-color` | `rgba(190, 215, 255, 0.85)` | |
| `--sp-checkbox-halo-color` | `rgba(120, 170, 255, 0.55)` | |
| `--sp-checkbox-label-gap-size` | `calc(var(--sp-grid-base-size) * 2)` | |
| `--sp-checkbox-mark-color` | `#eaf2ff` | |
| `--sp-checkbox-row-gap-size` | `calc(var(--sp-grid-base-size) * 2)` | |
---
## Checkbox.CheckboxGroup
```tsx
import { Checkbox } from "@inkorange/space-ui";
```
### Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `value` | `string[]` | required | The chosen values. Fully controlled; order is the caller's to keep. |
| `onValueChange` | `(value: string[]) => void` | required | Called with the new list whenever an item is ticked or unticked. |
| `disabled` | `boolean` | — | Disables every item at once. |
| `aria-label` | `string` | — | Names the set for assistive tech — "Planet types", say. Use it whenever the visible heading above the group is not already tied to it. |
| `className` | `string` | — | Merged onto the group wrapper. |
| `children` | `ReactNode` | — | One or more `CheckboxGroup.Item`. |
### Tokens
Set these on a class that wraps or targets the component.
| Token | Default | Notes |
| --- | --- | --- |
| `--sp-checkbox-box-size` | `16px` | |
| `--sp-checkbox-glow-color` | `rgba(190, 215, 255, 0.85)` | |
| `--sp-checkbox-halo-color` | `rgba(120, 170, 255, 0.55)` | |
| `--sp-checkbox-label-gap-size` | `calc(var(--sp-grid-base-size) * 2)` | |
| `--sp-checkbox-mark-color` | `#eaf2ff` | |
| `--sp-checkbox-row-gap-size` | `calc(var(--sp-grid-base-size) * 2)` | |
---
## Dialog
A modal window over the page, for a task that has to finish or be abandoned before anything else happens. It rides the platform's own ``, so the backdrop, the top layer, the Escape key and the focus trap are the browser's rather than an approximation of them. Opening moves focus inside; closing returns it to whatever opened it. For a decision with consequences — deleting, discarding, overwriting — reach for `AlertDialog`, which refuses to close by a click outside, so a misplaced click cannot answer the question for the reader. Compose it from the parts hanging off this name: `Dialog.Trigger`, `Dialog.Content`, `Dialog.Title`, `Dialog.Description` and `Dialog.Close`.
```tsx
import { Dialog } from "@inkorange/space-ui";
```
### Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `open` | `boolean` | — | Controls the dialog. Omit both this and onOpenChange to let the dialog manage its own state. |
| `onOpenChange` | `(o: boolean) => void` | — | Called whenever the dialog opens or closes, including via Escape or a backdrop click. |
| `children` | `ReactNode` | — | A `Trigger` and a `Content`, in any arrangement — this renders nothing itself, so they can sit anywhere beneath it. |
---
## Dialog.Close
Closes the dialog. Same shape as Trigger: wraps its child, adds a close on click, and adds no markup.
```tsx
import { Dialog } from "@inkorange/space-ui";
```
### Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `children` | `ReactNode` | — | The control that closes the dialog. Its own onClick still fires. |
---
## Dialog.Content
The dialog surface. Built on the native `` element, so the browser supplies the top layer, focus trapping and Escape handling rather than the library reimplementing them. Dismissible by Escape or a click on the backdrop; AlertDialog's Content deliberately is not.
```tsx
import { Dialog } from "@inkorange/space-ui";
```
### Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `size` | `"3" \| "4"` | `"3"` | Padding and radius step. 4 is roomier, for dialogs carrying a form. |
| `maxWidth` | `string` | — | Caps the dialog's width, e.g. "480px". Without it the dialog uses its own default and grows with its content. |
| `children` | `ReactNode` | — | Dialog contents. Include a Title — it is the accessible name. |
Also accepts every attribute of `React.HTMLAttributes` — 277 inherited props, of which the ones usually reached for are `onClick`, `onChange`, `onFocus`, `onBlur`, `onKeyDown`, `id`, `aria-label`, `tabIndex`, `title`, `autoFocus`, `defaultValue`.
---
## Dialog.Trigger
Opens the dialog. Chains onto its child's existing click handler rather than replacing it, and renders no element of its own — so the child stays whatever it already was, button or otherwise.
```tsx
import { Dialog } from "@inkorange/space-ui";
```
### Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `children` | `ReactNode` | — | The control that opens the dialog. Its own onClick still fires. |
---
## DropdownMenu
A button that opens a list of actions. Actions, not choices: every item does something when picked, and nothing in the menu holds a value. For choosing one of a set of values, that is `Select`, which looks similar and means something entirely different to a screen reader. Arrow keys move through the items, Home and End jump to the ends, Escape closes and returns focus to the button, and typing a letter jumps to the next item starting with it. The panel uses the browser's popover layer, so it stacks above the page without a z-index to maintain. Compose it from `DropdownMenu.Item`, `DropdownMenu.Label` and `DropdownMenu.Separator`.
```tsx
import { DropdownMenu } from "@inkorange/space-ui";
```
### Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `label` | `ReactNode` | required | The trigger's content. The trigger is always a Button, so this is what goes inside it — text, or an icon with `iconOnly`. |
| `align` | `"start" \| "end"` | `"start"` | Which edge of the trigger the menu lines up with. Use `end` when the trigger sits near the right edge and the menu would overflow. |
| `children` | `ReactNode` | — | `DropdownMenu.Item`, `.Separator` and `.Label`, in any order. |
Also accepts every attribute of `Omit` — 292 inherited props, of which the ones usually reached for are `onClick`, `onChange`, `disabled`, `value`, `type`, `name`, `onFocus`, `onBlur`, `onKeyDown`, `id`, `aria-label`, `tabIndex`, `title`, `autoFocus`, `defaultValue`.
---
## Flex
A flex row or column whose gap comes from the spacing scale, so layouts inherit the 8pt grid instead of restating it in pixels. `asChild` merges the layout onto its only child rather than adding a wrapper, for when an extra div would break a grid or a selector.
```tsx
import { Flex } from "@inkorange/space-ui";
```
### Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `asChild` | `boolean` | — | Render the single child element instead of a wrapper div, merging the flex styling onto it. Avoids a redundant layer in the DOM. |
| `gap` | `"1" \| "2" \| "3" \| "4" \| "5" \| "6"` | — | Gap step: 1=4px, 2=8px, 3=12px, 4=16px, 5=24px, 6=32px. Note step 3 is 12px — off the 8pt grid, kept for parity with the shipped app. |
| `direction` | `"row" \| "column"` | — | Main axis. Default `row`. |
| `align` | `"start" \| "center" \| "end" \| "baseline" \| "stretch"` | — | Cross-axis alignment (`align-items`). |
| `justify` | `"start" \| "center" \| "end" \| "between"` | — | Main-axis distribution (`justify-content`). |
| `wrap` | `"wrap" \| "nowrap"` | — | Wrapping behaviour. Default is the browser's `nowrap`. |
| `children` | `ReactNode` | — | The items to lay out. |
Also accepts every attribute of `SpacingProps` and `React.HTMLAttributes` — 282 inherited props, of which the ones usually reached for are `onClick`, `onChange`, `onFocus`, `onBlur`, `onKeyDown`, `id`, `aria-label`, `tabIndex`, `title`, `autoFocus`, `defaultValue`.
---
## Grid
A simple column grid, 1-3 wide, with the same gap scale as Flex. Pass an object to `columns` for responsive counts. Deliberately narrow: anything more elaborate is a job for CSS grid directly rather than a prop API that would end up reinventing it.
```tsx
import { Grid } from "@inkorange/space-ui";
```
### Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `columns` | `Cols \| { initial?: Cols; sm?: Cols; md?: Cols }` | — | Column count, 1–3. Pass an object for responsive counts: `{ initial: "1", sm: "2", md: "3" }` — `initial` applies below the first breakpoint. |
| `gap` | `"1" \| "2" \| "3" \| "4" \| "5" \| "6"` | — | Gap step: 1=4px, 2=8px, 3=12px, 4=16px, 5=24px, 6=32px. Same scale as Flex. |
| `children` | `ReactNode` | — | The cells. |
Also accepts every attribute of `SpacingProps` and `React.HTMLAttributes` — 282 inherited props, of which the ones usually reached for are `onClick`, `onChange`, `onFocus`, `onBlur`, `onKeyDown`, `id`, `aria-label`, `tabIndex`, `title`, `autoFocus`, `defaultValue`.
### Tokens
Set these on a class that wraps or targets the component.
| Token | Default | Notes |
| --- | --- | --- |
| `--sp-grid-base-size` | `8px` | |
---
## Heading
A section heading. `as` sets the heading level and `size` sets the appearance, deliberately apart — so a visually small heading can still be an h2 where the document structure calls for one. Shares size steps 4-6 with Text, so a heading and body copy on the same line align.
```tsx
import { Heading } from "@inkorange/space-ui";
```
### Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `as` | `"h1" \| "h2" \| "h3" \| "h4"` | `"h2"` | Heading level to render. Default `h2`. Independent of `size`, so document outline stays correct without dictating appearance. |
| `size` | `"4" \| "5" \| "6" \| "7" \| "8" \| "9"` | — | Font size step: 4=18px, 5=20px, 6=24px, 7=28px, 8=35px, 9=60px. Shares steps 4–6 with Text, so a Heading and Text can sit on the same line and match. |
| `color` | `"muted" \| "danger"` | — | Semantic colour role. Omit for primary heading colour. |
| `align` | `"center"` | — | Centres the text. There is no `left`/`right` — headings are start-aligned unless deliberately centred. |
| `children` | `ReactNode` | — | The heading text. |
Also accepts every attribute of `SpacingProps` and `Omit, "color">` — 281 inherited props, of which the ones usually reached for are `onClick`, `onChange`, `onFocus`, `onBlur`, `onKeyDown`, `id`, `aria-label`, `tabIndex`, `title`, `autoFocus`, `defaultValue`.
---
## IconToggle
A segmented icon toggle: every option visible at once in a single pill, the selected one reading as active, and a click on any of them selecting it. Reach for it to switch between modes or views of the same thing, where the options are few and each has an obvious glyph. The alternative pattern — one button that shows the mode you are NOT in and swaps its own icon on click — is deliberately not what this does: the control's meaning changes under the cursor, and nobody can tell whether the icon means "you are here" or "go here". Generic over the option set, so the values stay your own union type.
```tsx
import { IconToggle } from "@inkorange/space-ui";
```
### Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `options` | `IconToggleOption[]` | required | The segments, in display order. Every option is always visible — this is a segmented control, not a menu. |
| `value` | `NoInfer` | required | The selected option's value. Fully controlled. NoInfer so V is derived from `options` alone. Without it a value that is not among the options simply widened V to include itself, which made the generic silently accept the one mistake it looks like it should catch. |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Which way the pill runs. `vertical` for a rail down the edge of a viewport, where a horizontal strip would eat the width. Default `horizontal`. |
| `className` | `string` | `""` | Merged onto the group wrapper. |
### IconToggleOption
The shape behind `options`.
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `value` | `V` | required | |
| `icon` | `ReactNode` | required | |
| `label` | `string` | required | Used for aria-label and the hover tooltip. |
### Tokens
Set these on a class that wraps or targets the component.
| Token | Default | Notes |
| --- | --- | --- |
| `--sp-icon-toggle-active-from-color` | `rgba(90, 130, 220, 0.28)` | |
| `--sp-icon-toggle-active-rim-color` | `rgba(140, 170, 255, 0.45)` | |
| `--sp-icon-toggle-active-to-color` | `rgba(90, 130, 220, 0.35)` | |
| `--sp-icon-toggle-label-color` | `#dbe6ef` | |
| `--sp-icon-toggle-label-dim-color` | `rgba(219, 230, 239, 0.55)` | |
---
## Link
An inline anchor, styled to sit inside running text without disturbing its rhythm. Sizes match Text's first three steps. Use `asChild` to wrap a router's own link component rather than nesting two anchors, which is invalid and breaks keyboard navigation.
```tsx
import { Link } from "@inkorange/space-ui";
```
### Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `asChild` | `boolean` | — | Render the single child element instead of an ``, merging link styling onto it. For wrapping a router's own Link component without nesting two anchors. |
| `size` | `"1" \| "2" \| "3"` | — | Font size step: 1=12px, 2=14px, 3=16px. Matches Text's first three steps so inline links sit flush with surrounding copy. |
| `children` | `ReactNode` | — | Link text. With `asChild`, the single element to style as a link. |
Also accepts every attribute of `SpacingProps` and `Omit, "color">` — 289 inherited props, of which the ones usually reached for are `onClick`, `onChange`, `type`, `onFocus`, `onBlur`, `onKeyDown`, `id`, `aria-label`, `tabIndex`, `title`, `autoFocus`, `defaultValue`.
---
## Loader
An orbital spinner, for work whose length you cannot measure: a small pulsing planet at the centre of a static ring, with a moon travelling it. Where you CAN measure the work, use `Progress` instead — a spinner that could have been a bar tells the reader less than it knows. The motion is pure CSS and runs on the compositor, so it keeps turning smoothly while the main thread is busy doing the thing it is waiting on — which is exactly when a loader is on screen.
```tsx
import { Loader } from "@inkorange/space-ui";
```
### Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `size` | `"sm" \| "md" \| "lg"` | `"md"` | sm = inline/compact spots. md = default. lg = full-panel takeovers. |
| `label` | `string` | — | Visible caption under the spinner; also doubles as the accessible name. |
| `className` | `string` | — | Merged onto the wrapper. |
### Tokens
Set these on a class that wraps or targets the component.
| Token | Default | Notes |
| --- | --- | --- |
| `--sp-loader-moon-size` | `4px` | Diameter of the dot orbiting the ring. Stays centred on the ring's edge at any size. |
| `--sp-loader-orbit-size` | `24px` | Diameter of the ring the moon travels. Sets the loader's overall footprint; the planet and moon do not scale with it. |
| `--sp-loader-planet-size` | `8px` | Diameter of the pulsing disc at the centre. Raise it for a denser loader, lower it for a thinner ring-led one. |
---
## Message
A banner that tells the reader something about the state of the page — a risk, a result, a failure. The three variants do not merely change hue. `info` is lit like the rest of the system, cool and calm; `warning` warms the rim to amber; `alert` uses the ember treatment, the same warm limb the destructive Button wears. Each carries its own glyph so the kind of message reads before the words do. For a decision the reader must make, reach for `AlertDialog` instead — a message states, it does not ask.
```tsx
import { Message } from "@inkorange/space-ui";
```
### Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `variant` | `MessageVariant` | `"info"` | Which message this is. `info` states something, `warning` flags a risk worth acting on, `alert` reports something already wrong. Default `info`. |
| `title` | `ReactNode` | — | An optional bold line above the body. Omit for a single-line message — a title that merely restates the body is noise. This shadows the HTML `title` attribute, which took a string and showed a browser tooltip. A tooltip on a banner nobody hovers was never the useful reading of the name. |
| `icon` | `ReactNode` | — | Replaces the variant's own glyph. Pass `null` for no icon at all. |
| `children` | `ReactNode` | — | The message. |
Also accepts every attribute of `Omit, "title">` — 276 inherited props, of which the ones usually reached for are `onClick`, `onChange`, `onFocus`, `onBlur`, `onKeyDown`, `id`, `aria-label`, `tabIndex`, `autoFocus`, `defaultValue`.
### Tokens
Set these on a class that wraps or targets the component.
| Token | Default | Notes |
| --- | --- | --- |
| `--sp-message-alert-rim-color` | `#ff9592` | |
| `--sp-message-alert-wash-color` | `rgba(255, 90, 60, 0.12)` | |
| `--sp-message-info-rim-color` | `#70b8ff` | |
| `--sp-message-info-wash-color` | `rgba(0, 144, 255, 0.1)` | |
| `--sp-message-warning-rim-color` | `#ffca16` | |
| `--sp-message-warning-wash-color` | `rgba(250, 130, 0, 0.12)` | |
---
## Pagination
Page-by-page navigation for a long list. Holds no state: give it the pagination object you already have and it reports which page was clicked. Every control is the same limb-lit glass as Select and Button, but only the current page's rim orbits. Nine arcs turning in one row would be noise; one turning is where you are. Previous and Next stay in place when they cannot be used, so the numbers never slide sideways on the first and last page.
```tsx
import { Pagination } from "@inkorange/space-ui";
```
### Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `pagination` | `PaginationState` | required | Where the list stands: the current page, the page size, and the total item count. Pass the object your API returns; the page count is derived from it. With one page or fewer nothing renders. |
| `onPageClick` | `(page: number) => void` | required | Called with the page the reader chose — a number, Previous or Next. Not called for the page already showing. Fetch or slice, then hand back an updated `pagination`. |
| `siblings` | `number` | `1` | Page numbers shown either side of the current one. Default 1. |
| `animated` | `boolean` | `true` | Ambient motion: the lit arc orbiting the current page's rim. The glass skin is always applied — only its motion is optional. Default true. |
Also accepts every attribute of `Omit, "children">` — 277 inherited props, of which the ones usually reached for are `onClick`, `onChange`, `onFocus`, `onBlur`, `onKeyDown`, `id`, `aria-label`, `tabIndex`, `title`, `autoFocus`, `defaultValue`.
### PaginationState
The shape behind `pagination`. The data Pagination needs, in the shape most APIs return beside a page of results. Pass it as `pagination`. When `onPageClick` reports a page, hand back a copy with only `page` changed — the component works out everything else, including how many pages there are, from these three numbers. With `totalItems` at or below `pageSize` there is only one page, and the component renders nothing.
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `page` | `number` | required | The page being shown, counting from 1. If your API counts from 0, add one before passing it in. A page past either end is clamped to the first or last rather than rejected. |
| `pageSize` | `number` | required | How many items one page holds — the size you requested, not how many came back on the final page. Must be above zero. |
| `totalItems` | `number` | required | How many items exist across every page: the full result count, not the length of the page you are showing. Usually the `total` or `count` field of the response. |
### Tokens
Set these on a class that wraps or targets the component.
| Token | Default | Notes |
| --- | --- | --- |
| `--sp-pagination-item-size` | `40px` | |
---
## PieChart
Parts of a whole, as a circle. Worth saying plainly: a pie is the weakest way to compare sizes — people read angles badly — and it is the wrong chart for more than a handful of slices, for values that are not parts of one total, or for change over time. Where the comparison is the point, `BarList` is easier to read. Where a pie is right is a whole that splits a few ways and a reader who needs to see "most of it" at a glance. So this one is built to stay readable: every slice keeps a hairline of the surface between it and its neighbour, the legend carries the names and the figures, and a donut puts the total in the middle where it can be read exactly rather than estimated.
```tsx
import { PieChart } from "@inkorange/space-ui";
```
### Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `slices` | `PieSlice[]` | required | The slices, in order. Sort before passing — usually largest first, which is what makes a pie readable at all. |
| `donut` | `boolean` | `false` | Cut the middle out, making a donut. The hole gives the total somewhere to live, which is usually worth more than the extra ink. |
| `label` | `React.ReactNode` | — | Shown in a donut's middle, under the total. |
| `size` | `number` | `180` | How wide the circle is, in pixels. Default 180. |
| `legend` | `boolean` | `true` | Show the legend. Default true: a pie without one is a colour quiz. |
| `animated` | `boolean` | `true` | The lit finish, and the sweep on its first draw. Default true. |
| `formatValue` | `(value: number) => string` | — | How a value is written, in the legend and the middle. |
| `onSliceClick` | `(slice: PieSlice, index: number) => void` | — | Called when a slice is chosen. |
| `aria-label` | `string` | — | Says what the whole shows. |
Also accepts every attribute of `Omit, "onSelect">` — 276 inherited props, of which the ones usually reached for are `onClick`, `onChange`, `onFocus`, `onBlur`, `onKeyDown`, `id`, `tabIndex`, `title`, `autoFocus`, `defaultValue`.
### PieSlice
The shape behind `slices`, `onSliceClick`.
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `label` | `string` | required | The slice's name — always drawn in the legend, never colour alone. |
| `value` | `number` | required | Its size. Negative values are dropped: a pie has no way to show one. |
| `color` | `string` | — | Overrides this slice's colour. |
### Tokens
Set these on a class that wraps or targets the component.
| Token | Default | Notes |
| --- | --- | --- |
| `--sp-pie-chart-gap-size` | `2px` | |
---
## Popover
A button that opens a floating panel of anything — a small form, a set of filters, a prompt — without leaving the page. It rides the browser's own popover layer, so the panel stacks above everything without a z-index arms race and closes on Escape or a click elsewhere by the platform's rules rather than an approximation of them. It sits next to its trigger, flips above it when there is no room below, and stays attached to it if the page scrolls or the layout around it shifts. Opening moves focus into the panel so a keyboard user lands on its first control; closing with Escape hands focus back to the trigger. The panel is labelled by the trigger, so it announces as what the button said it was.
```tsx
import { Popover } from "@inkorange/space-ui";
```
### Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `label` | `ReactNode` | required | What the trigger shows. The trigger is always a Button, so this is its content — text, or an icon with `iconOnly` and an `aria-label`. |
| `children` | `ReactNode` | — | The panel's contents. Anything: a form, a filter set, a prompt. For a list of actions reach for DropdownMenu; for a line of text, Tooltip. |
| `open` | `boolean` | — | Controls the panel. Omit both this and `onOpenChange` to let the popover manage itself. |
| `onOpenChange` | `(open: boolean) => void` | — | Called whenever the panel opens or closes, including by Escape or a click elsewhere. |
| `defaultOpen` | `boolean` | `false` | Open on first render when uncontrolled. Focus is left where it is, so a popover shown on arrival never steals the page's focus. |
| `side` | `"top" \| "bottom"` | `"bottom"` | Which side of the trigger the panel prefers. It flips to the other side when there is no room. Default `bottom`. |
| `align` | `"start" \| "center" \| "end"` | `"start"` | Which edge of the trigger the panel lines up with. Use `end` for a trigger near the right of the viewport. Default `start`. |
| `panelClassName` | `string` | — | Merged onto the panel. |
Also accepts every attribute of `Omit` — 292 inherited props, of which the ones usually reached for are `onClick`, `onChange`, `disabled`, `value`, `type`, `name`, `onFocus`, `onBlur`, `onKeyDown`, `id`, `aria-label`, `tabIndex`, `title`, `autoFocus`, `defaultValue`.
### Tokens
Set these on a class that wraps or targets the component.
| Token | Default | Notes |
| --- | --- | --- |
| `--sp-popover-panel-max-width` | `22rem` | |
---
## Progress
A determinate bar for work you can measure — use Loader when you cannot. Speaks the same visual language as Slider, because a progress bar and a slider track are the same object: one you watch, one you drag.
```tsx
import { Progress } from "@inkorange/space-ui";
```
### Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `value` | `number` | required | Current progress, clamped between 0 and `max`. |
| `max` | `number` | `100` | The value representing complete. |
Also accepts every attribute of `React.HTMLAttributes` — 278 inherited props, of which the ones usually reached for are `onClick`, `onChange`, `onFocus`, `onBlur`, `onKeyDown`, `id`, `aria-label`, `tabIndex`, `title`, `autoFocus`, `defaultValue`.
### Tokens
Set these on a class that wraps or targets the component.
| Token | Default | Notes |
| --- | --- | --- |
| `--sp-progress-fill-color` | `radial-gradient(1.2px 1.2px at 22% 35%, rgb(v…` | |
| `--sp-progress-height` | `6px` | |
| `--sp-progress-track-color` | `linear-gradient( 180deg, rgb(var(--sp-glass-rgb` | |
---
## RadioGroup
Exactly one choice from a set you can see all of at once. Use it where the options matter enough to be read side by side and there are few enough to show — up to about seven. Past that a `Select` keeps the page calm. Where several answers can be true at the same time, that is `CheckboxGroup`. Hold the chosen value and pass `onValueChange`. Each orb ignites when it is chosen, arrow keys move between the options the way a native radio group does, and a label that wraps to two lines keeps its orb beside the first line rather than drifting to the middle. Compose it from `RadioGroup.Item`.
```tsx
import { RadioGroup } from "@inkorange/space-ui";
```
### Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `value` | `string` | required | The selected item's value. Fully controlled. |
| `onValueChange` | `(v: string) => void` | required | Called with the newly selected value. |
| `disabled` | `boolean` | — | Disables every item at once. |
| `className` | `string` | — | Merged onto the group wrapper. |
| `children` | `ReactNode` | — | One or more `RadioGroup.Item`. |
### Tokens
Set these on a class that wraps or targets the component.
| Token | Default | Notes |
| --- | --- | --- |
| `--sp-radio-group-label-gap-size` | `calc(var(--sp-grid-base-size) * 2)` | |
| `--sp-radio-group-orb-glow-color` | `rgba(190, 215, 255, 0.85)` | |
| `--sp-radio-group-orb-halo-color` | `rgba(120, 170, 255, 0.55)` | |
| `--sp-radio-group-orb-highlight-color` | `#cfe0ff` | |
| `--sp-radio-group-orb-mid-color` | `#8fb4ff` | |
| `--sp-radio-group-orb-rim-color` | `rgba(150, 195, 255, 0.9)` | |
| `--sp-radio-group-row-gap-size` | `calc(var(--sp-grid-base-size) * 2)` | |
---
## Select
Choosing one value from a list, with the list kept out of the way until it is wanted. The right control when the options are many, or dull, or both — a country, a size, a sort order. Where there are few options and each one deserves to be read, `RadioGroup` shows them all without a click. Where the items are actions rather than values, that is `DropdownMenu`. The panel opens on the browser's popover layer, which is what lets it escape a dialog, a transformed ancestor or a clipping overflow and sit above the page at its full height. It flips above the trigger when there is no room below, follows the trigger if the page scrolls, and closes on Escape with focus handed back. Typing jumps to a matching option. Compose it from `Select.Item`.
```tsx
import { Select } from "@inkorange/space-ui";
```
### Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `value` | `string` | required | The selected item's value. Fully controlled. |
| `onValueChange` | `(v: string) => void` | required | Called with the newly selected value. |
| `disabled` | `boolean` | — | Blocks the trigger and removes it from the tab order. |
| `placeholder` | `string` | — | Shown when nothing is selected. Counts toward the control's width, so selecting an option never resizes it. |
| `animated` | `boolean` | `true` | Ambient motion: the lit arc orbiting the rim. The glass skin itself is always applied. Default true. |
| `children` | `ReactNode` | — | One or more `Select.Item`. Anything else is ignored for selection but still rendered in the panel. |
Also accepts every attribute of `Omit, "value" | "children" | "onChange">` — 284 inherited props, of which the ones usually reached for are `onClick`, `type`, `name`, `onFocus`, `onBlur`, `onKeyDown`, `id`, `aria-label`, `tabIndex`, `title`, `autoFocus`, `defaultValue`.
### Tokens
Set these on a class that wraps or targets the component.
| Token | Default | Notes |
| --- | --- | --- |
| `--sp-select-panel-color` | `#101216` | |
| `--sp-select-panel-max-width` | `min(90vw, 28rem)` | |
| `--sp-select-trigger-max-width` | `20rem` | |
---
## Separator
A hairline rule for dividing content within a surface, where a full panel boundary would be too heavy. Exposed as `role="separator"`, so assistive technology hears the division too.
```tsx
import { Separator } from "@inkorange/space-ui";
```
### Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `size` | `"1" \| "2" \| "3" \| "4"` | `"4"` | Length of the rule, 1 (shortest) to 4 (full width of its container). |
Also accepts every attribute of `React.HTMLAttributes` — 278 inherited props, of which the ones usually reached for are `onClick`, `onChange`, `onFocus`, `onBlur`, `onKeyDown`, `id`, `aria-label`, `tabIndex`, `title`, `autoFocus`, `defaultValue`.
---
## Slider
A range control: one thumb for a value, two for a span between values. Each thumb is a real ` `, so arrow keys, Home, End and Page Up/Down all work without this component reimplementing them, and a screen reader announces a slider because it is one. What is drawn — the glass tube, the lit fill, the little planet of a thumb — sits on top of inputs that are invisible but still doing the work. With two thumbs they cannot cross: each is clamped to the other, so the array stays in order and a range can never read back inverted. `minDistance` keeps a gap between them where a zero-width range would be meaningless.
```tsx
import { Slider } from "@inkorange/space-ui";
```
### Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `value` | `number[]` | required | The value, as an array. One number is one thumb; two is a range, read as `[start, end]` and kept in that order — the thumbs cannot swap places, so index 0 is always the lower of the two. Fully controlled. |
| `onValueChange` | `(value: number[]) => void` | required | Called with the whole array as a thumb moves, in the same shape it was given. |
| `min` | `number` | required | Lower bound. |
| `max` | `number` | required | Upper bound. |
| `step` | `number` | `1` | The increment a thumb moves by, and the grid every value lands on: with `step={10}` over 0–100 a thumb stops at 0, 10, 20 … and nothing between, whether it is dragged, pressed on the track, or walked with the arrow keys. Counted from `min`, so a range of 5–105 with a step of 10 lands on 5, 15, 25 … Default 1, the same as a bare ` `. |
| `minDistance` | `number` | `0` | How close the two thumbs may come, in the value's own units. 0 lets them meet; a positive number keeps a gap, for a range that would mean nothing empty — "between 2 and 2 bedrooms". Ignored with one thumb. |
| `disabled` | `boolean` | — | Blocks interaction and removes it from the tab order. |
| `className` | `string` | — | Merged onto the wrapper. |
| `aria-label` | `string` | — | Accessible name. Required unless a visible label references it, since a slider has no text of its own. With two thumbs each one needs its own name, so this is suffixed — "Mass minimum", "Mass maximum" — unless `thumbLabels` says otherwise. |
| `thumbLabels` | `[string, string]` | — | Names for the two thumbs, when the generated ones do not fit: a price range might want "From" and "To". |
### Tokens
Set these on a class that wraps or targets the component.
| Token | Default | Notes |
| --- | --- | --- |
| `--sp-slider-range-from-color` | `rgba(70, 110, 210, 0.85)` | |
| `--sp-slider-thumb-glow-hover-color` | `rgba(130, 175, 255, 0.75)` | |
| `--sp-slider-thumb-highlight-color` | `#e6f0ff` | |
| `--sp-slider-thumb-limb-color` | `#0b1330` | |
| `--sp-slider-thumb-mid-color` | `#86abe8` | |
| `--sp-slider-thumb-rim-color` | `rgba(160, 195, 255, 0.75)` | |
| `--sp-slider-thumb-rim-hover-color` | `rgba(190, 215, 255, 0.9)` | |
| `--sp-slider-thumb-shade-color` | `#23386b` | |
---
## Tabs
Several panels sharing one piece of the page, one visible at a time. Right for views of the same subject that a reader moves between and compares — overview, history, settings for one record. Wrong for steps in a sequence, where the reader needs to know how far along they are, and wrong for content that should be findable by search or linkable on its own: a hidden panel is invisible to both. Arrow keys move between the tabs, Home and End jump to the ends, and only the selected tab is in the tab order, so one press of Tab leaves the strip for the panel instead of walking every tab in it. Compose it from `Tabs.List`, `Tabs.Trigger` and `Tabs.Content`.
```tsx
import { Tabs } from "@inkorange/space-ui";
```
### Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `value` | `string` | required | The selected tab's value. Fully controlled — there is no default. |
| `onValueChange` | `(v: string) => void` | required | Called with the newly selected tab's value. |
| `className` | `string` | — | Merged onto the wrapper div, which is otherwise unstyled. |
| `children` | `ReactNode` | — | A `Tabs.List` and the `Tabs.Content`s it switches between. |
### Tokens
Set these on a class that wraps or targets the component.
| Token | Default | Notes |
| --- | --- | --- |
| `--sp-tabs-active-edge-color` | `rgba(190, 215, 255, 0.45)` | |
| `--sp-tabs-active-from-color` | `rgba(30, 40, 76, 0.9)` | |
| `--sp-tabs-hover-from-color` | `rgba(24, 32, 62, 0.75)` | |
| `--sp-tabs-hover-to-color` | `rgba(10, 14, 28, 0.45)` | |
| `--sp-tabs-rest-from-color` | `rgba(15, 20, 40, 0.6)` | |
| `--sp-tabs-rest-to-color` | `rgba(7, 10, 20, 0.3)` | |
| `--sp-tabs-surface-color` | `#16181b` | |
---
## Text
Body copy. Size, weight and colour are presentational and independent of the element, so `as` stays free to be whatever the document actually needs — a `p` for prose, a `label` for a field, a `span` inline. Colour is a role, not a hue: use `muted` for secondary copy and reserve the status roles for text that genuinely carries that meaning.
```tsx
import { Text } from "@inkorange/space-ui";
```
### Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `as` | `"span" \| "div" \| "p" \| "label"` | `"span"` | Element to render. Default `span`. Use `p` for prose and `label` (with `htmlFor`) for form labels — size is presentational, so the element stays free to be the semantically correct one. |
| `size` | `"1" \| "2" \| "3" \| "4" \| "5" \| "6"` | — | Font size step: 1=12px, 2=14px, 3=16px, 4=18px, 5=20px, 6=24px. Line height and letter spacing move with it. Omit to inherit. |
| `weight` | `"medium" \| "bold"` | — | 500 or 700. Omit for the inherited weight. |
| `color` | `"muted" \| "danger" \| "success" \| "warning"` | — | Semantic colour role. `muted` is the standard secondary-text token; the rest carry meaning, so avoid them for emphasis alone. Omit for primary text. |
| `htmlFor` | `string` | — | Id of the control this labels. Only meaningful with `as="label"`. |
| `children` | `ReactNode` | — | The text. |
Also accepts every attribute of `SpacingProps` and `Omit, "color">` — 281 inherited props, of which the ones usually reached for are `onClick`, `onChange`, `onFocus`, `onBlur`, `onKeyDown`, `id`, `aria-label`, `tabIndex`, `title`, `autoFocus`, `defaultValue`.
---
## TextArea
Multi-line text entry. `rows` sets the initial height; it grows with its container's width and resizes vertically only, since horizontal drag would break the surrounding layout.
```tsx
import { TextArea } from "@inkorange/space-ui";
```
### Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `className` | `string` | — | Merged onto the field wrapper, not the inner textarea. |
| `animated` | `boolean` | `true` | Ambient motion on the rim. The glass skin is always applied. Default true. |
| `style` | `React.CSSProperties` | — | Applied to the field wrapper — set width here. |
Also accepts every attribute of `Omit, "className" | "style">` — 290 inherited props, of which the ones usually reached for are `onClick`, `onChange`, `disabled`, `placeholder`, `value`, `required`, `readOnly`, `name`, `onFocus`, `onBlur`, `onKeyDown`, `maxLength`, `id`, `aria-label`, `tabIndex`, `title`, `autoFocus`, `defaultValue`.
---
## TextField
A single line of text the reader types. A real ` ` underneath, so `value`, `onChange`, `type`, `required`, `autoComplete` and the rest of the native attributes all work as they already do, and the browser's own autofill and validation come along with them. For more than a line, use `TextArea`. Put an icon, a unit or a small button beside the text with `TextField.Slot`. It sits inside the field's rim and keeps its own padding, so the text never runs underneath it.
```tsx
import { TextField } from "@inkorange/space-ui";
```
### Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `className` | `string` | — | Merged onto the field wrapper, not the inner input. |
| `animated` | `boolean` | `true` | Ambient motion on the rim. The glass skin is always applied. Default true. |
| `style` | `React.CSSProperties` | — | Applied to the field wrapper — set width here. |
| `children` | `ReactNode` | — | Slots rendered inside the field, alongside the input. |
Also accepts every attribute of `Omit, "className" | "style">` — 305 inherited props, of which the ones usually reached for are `onClick`, `onChange`, `disabled`, `placeholder`, `value`, `type`, `required`, `readOnly`, `name`, `onFocus`, `onBlur`, `onKeyDown`, `maxLength`, `id`, `aria-label`, `tabIndex`, `title`, `autoFocus`, `defaultValue`.
---
## Tooltip
A short label revealed on hover or focus, for naming a control whose icon is not self-evident. Never put information here that a reader needs: a tooltip is unreachable on touch and easy to miss. If it must be read, put it on the page.
```tsx
import { Tooltip } from "@inkorange/space-ui";
```
### Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `label` | `string` | required | The text to show. Nothing renders when this is empty. |
| `side` | `"top" \| "bottom" \| "left" \| "right"` | `"bottom"` | Preferred side; flips to the opposite one when there isn't room. `left`/`right` are for a control in a vertical stack, where a tooltip above or below would land on the neighbouring item. |
| `children` | `React.ReactElement` | required | The element the tooltip describes. Receives the hover and focus handlers, so it must accept a ref. |
---
## Icons
Imported from the same entry point and taking the standard SVG props, so
`className`, `width`, `aria-hidden` and the rest all work:
```tsx
import { BookmarkIcon, ChatBubbleIcon, CheckIcon } from "@inkorange/space-ui";
```
- `BookmarkIcon`
- `ChatBubbleIcon`
- `CheckIcon`
- `ChevronDownIcon`
- `ChevronLeftIcon`
- `ChevronRightIcon`
- `ChevronUpIcon`
- `CrossCircledIcon`
- `EnterFullScreenIcon`
- `ExclamationTriangleIcon`
- `ExitFullScreenIcon`
- `HeartFilledIcon`
- `HeartIcon`
- `InfoCircledIcon`
- `MagnifyingGlassIcon`
- `MinusIcon`
- `Pencil1Icon`
- `PersonIcon`
- `PlusIcon`
- `QuestionMarkCircledIcon`
- `Share2Icon`
- `TrashIcon`
- `XIcon`