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

@inkorange/space-ui v1.11.0 · MIT · interactive gallery · source · npm · llms.txt

npm install @inkorange/space-ui

import "@inkorange/space-ui/styles.css";
import { Button } from "@inkorange/space-ui";

Rules for writing code against this library

  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.
    import "@inkorange/space-ui/styles.css";
  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.
    /* Right */
    .panel { padding: var(--spacing-md); gap: var(--spacing-sm); }
    
    /* Wrong — off the grid, and it will not retune with the library */
    .panel { padding: 16px; gap: 8px; }
  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.
    /* Right */
    .featured { --sp-carousel-view-count: 2.5; }
    
    /* Wrong */
    <Carousel style={{ "--sp-carousel-view-count": 2.5 }} />
  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 <Button fullWidth />, 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.
    const [size, setSize] = useState("md");
    <Select value={size} onValueChange={setSize}>…</Select>
  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.

Scales

Spacing is an 8pt grid: every step is a multiple of --sp-grid-base-size (8px). Type is sized in rem. Control heights come from the ladder, so a Button and the Select beside it are the same height.

TokenValue
--spacing-xs4px
--spacing-sm8px
--spacing-md16px
--spacing-lg24px
--spacing-xl32px
--spacing-2xl40px
--sp-control-height-sm36px
--sp-control-height40px
--sp-control-height-lg48px
--sp-font-xs12px
--sp-font-sm14px
--sp-font-md16px
--sp-font-xl24px

Components

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.

import { Autocomplete } from "@inkorange/space-ui";

Props

PropTypeDefaultNotes
valuestringrequiredThe text in the field. Fully controlled.
onValueChange(value: string) => voidrequiredCalled on every keystroke.
optionsAutocompleteOption[]requiredThe 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) => voidrequiredCalled when a row is chosen by click or Enter.
iconReactNode—Renders a leading glyph inside the field — a magnifier, usually.
caseSensitivebooleanfalseMatch exactly as typed, so trappist no longer finds TRAPPIST. Default off, which is what nearly every search wants.
preFilteredbooleanfalseThe 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.
loadingbooleanfalseReplaces the list with a waiting message while results are on their way.
loadingMessageReactNode"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…".
emptyMessageReactNode—Shown when there are no options and nothing is loading. Leave it out to show no panel at all rather than an empty one.
footerReactNode—A line under the list, outside its scroll area — "showing 50 of 2,700", a hint, a keyboard legend.
animatedbooleantrueAmbient motion on the field's rim. The glass skin is always applied. Default true.
classNamestring—Merged onto the wrapper, which is what you size.
styleReact.CSSProperties—Applied to the wrapper — set width here.

Also accepts every attribute of Omit< React.InputHTMLAttributes<HTMLInputElement>, "value" | "onChange" | "onSelect" | "className" | "style" > — 303 inherited props.

AutocompleteOption

The shape behind options, onSelect.

FieldTypeRequiredNotes
valuestringrequiredHanded back to onSelect when this row is chosen.
labelReactNoderequiredThe row's main text.
metaReactNode—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.
disabledboolean—Shown, but skipped by the keyboard and unselectable.
searchstring—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

TokenDefaultNotes
--sp-autocomplete-option-highlight-colorrgb(150 190 255 / 0.14)
--sp-autocomplete-panel-max-height320px

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.

import { Badge } from "@inkorange/space-ui";

Props

PropTypeDefaultNotes
colorBadgeColor"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.
wrapboolean—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.
childrenReactNode—Label text. Keep it short — a badge that wraps stops reading as one.

Also accepts every attribute of SpacingProps and Omit<React.HTMLAttributes<HTMLSpanElement>, "color"> — 281 inherited props.

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.

import { BarList } from "@inkorange/space-ui";

Props

PropTypeDefaultNotes
itemsBarListItem[]requiredThe rows, in the order they should appear. Sort before passing: the order is the caller's statement about the data, not this component's.
maxnumber—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.
showPercentbooleanfalseShow each row's share as a percentage beside its value.
formatValue(value: number) => string—How the value is written.
colorfulbooleanfalseGive every row its own colour from the series palette, rather than one colour for the set.
animatedbooleantrueFill 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-labelstring—Names the set for assistive tech: "Poll results", say.

Also accepts every attribute of Omit<React.HTMLAttributes<HTMLDivElement>, "onSelect"> — 276 inherited props.

BarListItem

The shape behind items, onItemClick.

FieldTypeRequiredNotes
labelstringrequiredThe row's name, at the left.
valuenumberrequiredHow long the bar is, against the largest value or an explicit max.
colorstring—Overrides this row's colour. One colour for the set reads as a ranking; a colour per row reads as categories.

Tokens

TokenDefaultNotes
--sp-bar-list-label-width96px
--sp-bar-list-track-height8px

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.

import { Box } from "@inkorange/space-ui";

Props

PropTypeDefaultNotes
childrenReactNode—Anything. Box adds no styling of its own.

Also accepts every attribute of SpacingProps and React.HTMLAttributes<HTMLDivElement> — 282 inherited props.

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

import { Button } from "@inkorange/space-ui";

Props

PropTypeDefaultNotes
iconOnlyboolean—Circular, icon-only sizing (e.g. the share glyph).
emberboolean—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).
fullWidthboolean—Stretch to the container width (sticky footers).
animatedbooleantrueAmbient 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<HTMLButtonElement> — 288 inherited props.

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.

import { Card } from "@inkorange/space-ui";

Props

PropTypeDefaultNotes
imageReactNode—A media element to sit at the top of the card, full-bleed to its edges. Pass whatever your framework renders — a plain <img>, a next/image, a <video> — 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.
childrenReactNode—The card's contents.

Also accepts every attribute of SpacingProps and React.HTMLAttributes<HTMLDivElement> — 282 inherited props.

Tokens

TokenDefaultNotes
--sp-card-image-ratio16 / 10

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.

import { Chart } from "@inkorange/space-ui";

Props

PropTypeDefaultNotes
type"bar" | "line" | "area"requiredHow 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.
categoriesstring[]requiredThe x axis, in order. Every series' data lines up with this by position, so they must be the same length.
seriesChartSeries[]requiredOne entry per dataset.
heightnumber240How tall the plot is, in pixels. The width comes from the container. Default 240.
stackedbooleanfalseStack 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.
legendboolean—Show the legend. Defaults to true for more than one series, which is when colour alone would otherwise be carrying the identity.
gridbooleantrueDraw the horizontal grid lines. Default true.
formatValue(value: number) => string—How a value is written, in labels and the tooltip.
tickCountnumber5Roughly how many labelled ticks the y axis gets. Default 5.
animatedbooleantrueThe 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.
partialFromnumber—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-labelstring—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<React.HTMLAttributes<HTMLDivElement>, "onClick"> — 276 inherited props.

ChartSeries

The shape behind series.

FieldTypeRequiredNotes
namestringrequiredNamed in the legend and the tooltip, so say what the numbers are.
data(number | null)[]requiredOne 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.
colorstring—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.

FieldTypeRequiredNotes
seriesstringrequiredThe series the point belongs to.
seriesIndexnumberrequired
categorystringrequiredThe category beneath it.
categoryIndexnumberrequired
valuenumberrequired

Tokens

TokenDefaultNotes
--sp-chart-axis-color#d6ebfd33
--sp-chart-grid-color#d6ebfd1a
--sp-chart-label-color#9aa3ad
--sp-chart-line-width2px
--sp-chart-mark-radius-size4px
--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.Items and hold one array instead of a boolean each.

import { Checkbox } from "@inkorange/space-ui";

Props

PropTypeDefaultNotes
checkedbooleanrequiredWhether it is ticked. Fully controlled.
onCheckedChange(checked: boolean) => voidrequiredCalled with the new state.
indeterminateboolean—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.
disabledboolean—Disables it.
aria-labelstring—Names it for assistive tech when there are no children to do so.
classNamestring—Merged onto the label.
childrenReactNode—The label. Part of the click target, not merely beside it.

Tokens

TokenDefaultNotes
--sp-checkbox-box-size16px
--sp-checkbox-glow-colorrgba(190, 215, 255, 0.85)
--sp-checkbox-halo-colorrgba(120, 170, 255, 0.55)
--sp-checkbox-label-gap-sizecalc(var(--sp-grid-base-size) * 2)
--sp-checkbox-mark-color#eaf2ff
--sp-checkbox-row-gap-sizecalc(var(--sp-grid-base-size) * 2)

Checkbox.CheckboxGroup

import { Checkbox } from "@inkorange/space-ui";

Props

PropTypeDefaultNotes
valuestring[]requiredThe chosen values. Fully controlled; order is the caller's to keep.
onValueChange(value: string[]) => voidrequiredCalled with the new list whenever an item is ticked or unticked.
disabledboolean—Disables every item at once.
aria-labelstring—Names the set for assistive tech — "Planet types", say. Use it whenever the visible heading above the group is not already tied to it.
classNamestring—Merged onto the group wrapper.
childrenReactNode—One or more CheckboxGroup.Item.

Tokens

TokenDefaultNotes
--sp-checkbox-box-size16px
--sp-checkbox-glow-colorrgba(190, 215, 255, 0.85)
--sp-checkbox-halo-colorrgba(120, 170, 255, 0.55)
--sp-checkbox-label-gap-sizecalc(var(--sp-grid-base-size) * 2)
--sp-checkbox-mark-color#eaf2ff
--sp-checkbox-row-gap-sizecalc(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 <dialog>, 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.

import { Dialog } from "@inkorange/space-ui";

Props

PropTypeDefaultNotes
openboolean—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.
childrenReactNode—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.

import { Dialog } from "@inkorange/space-ui";

Props

PropTypeDefaultNotes
childrenReactNode—The control that closes the dialog. Its own onClick still fires.

Dialog.Content

The dialog surface. Built on the native <dialog> 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.

import { Dialog } from "@inkorange/space-ui";

Props

PropTypeDefaultNotes
size"3" | "4""3"Padding and radius step. 4 is roomier, for dialogs carrying a form.
maxWidthstring—Caps the dialog's width, e.g. "480px". Without it the dialog uses its own default and grows with its content.
childrenReactNode—Dialog contents. Include a Title — it is the accessible name.

Also accepts every attribute of React.HTMLAttributes<HTMLDialogElement> — 277 inherited props.

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.

import { Dialog } from "@inkorange/space-ui";

Props

PropTypeDefaultNotes
childrenReactNode—The control that opens the dialog. Its own onClick still fires.

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.

import { Flex } from "@inkorange/space-ui";

Props

PropTypeDefaultNotes
asChildboolean—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.
childrenReactNode—The items to lay out.

Also accepts every attribute of SpacingProps and React.HTMLAttributes<HTMLDivElement> — 282 inherited props.

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.

import { Grid } from "@inkorange/space-ui";

Props

PropTypeDefaultNotes
columnsCols | { 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.
childrenReactNode—The cells.

Also accepts every attribute of SpacingProps and React.HTMLAttributes<HTMLDivElement> — 282 inherited props.

Tokens

TokenDefaultNotes
--sp-grid-base-size8px

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.

import { Heading } from "@inkorange/space-ui";

Props

PropTypeDefaultNotes
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.
childrenReactNode—The heading text.

Also accepts every attribute of SpacingProps and Omit<React.HTMLAttributes<HTMLHeadingElement>, "color"> — 281 inherited props.

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.

import { IconToggle } from "@inkorange/space-ui";

Props

PropTypeDefaultNotes
optionsIconToggleOption<V>[]requiredThe segments, in display order. Every option is always visible — this is a segmented control, not a menu.
valueNoInfer<V>requiredThe 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.
classNamestring""Merged onto the group wrapper.

IconToggleOption

The shape behind options.

FieldTypeRequiredNotes
valueVrequired
iconReactNoderequired
labelstringrequiredUsed for aria-label and the hover tooltip.

Tokens

TokenDefaultNotes
--sp-icon-toggle-active-from-colorrgba(90, 130, 220, 0.28)
--sp-icon-toggle-active-rim-colorrgba(140, 170, 255, 0.45)
--sp-icon-toggle-active-to-colorrgba(90, 130, 220, 0.35)
--sp-icon-toggle-label-color#dbe6ef
--sp-icon-toggle-label-dim-colorrgba(219, 230, 239, 0.55)

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.

import { Loader } from "@inkorange/space-ui";

Props

PropTypeDefaultNotes
size"sm" | "md" | "lg""md"sm = inline/compact spots. md = default. lg = full-panel takeovers.
labelstring—Visible caption under the spinner; also doubles as the accessible name.
classNamestring—Merged onto the wrapper.

Tokens

TokenDefaultNotes
--sp-loader-moon-size4pxDiameter of the dot orbiting the ring. Stays centred on the ring's edge at any size.
--sp-loader-orbit-size24pxDiameter of the ring the moon travels. Sets the loader's overall footprint; the planet and moon do not scale with it.
--sp-loader-planet-size8pxDiameter 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.

import { Message } from "@inkorange/space-ui";

Props

PropTypeDefaultNotes
variantMessageVariant"info"Which message this is. info states something, warning flags a risk worth acting on, alert reports something already wrong. Default info.
titleReactNode—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.
iconReactNode—Replaces the variant's own glyph. Pass null for no icon at all.
childrenReactNode—The message.

Also accepts every attribute of Omit<React.HTMLAttributes<HTMLDivElement>, "title"> — 276 inherited props.

Tokens

TokenDefaultNotes
--sp-message-alert-rim-color#ff9592
--sp-message-alert-wash-colorrgba(255, 90, 60, 0.12)
--sp-message-info-rim-color#70b8ff
--sp-message-info-wash-colorrgba(0, 144, 255, 0.1)
--sp-message-warning-rim-color#ffca16
--sp-message-warning-wash-colorrgba(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.

import { Pagination } from "@inkorange/space-ui";

Props

PropTypeDefaultNotes
paginationPaginationStaterequiredWhere 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) => voidrequiredCalled 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.
siblingsnumber1Page numbers shown either side of the current one. Default 1.
animatedbooleantrueAmbient 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<React.HTMLAttributes<HTMLElement>, "children"> — 277 inherited props.

PaginationState

The shape behind pagination.

FieldTypeRequiredNotes
pagenumberrequiredThe 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.
pageSizenumberrequiredHow many items one page holds — the size you requested, not how many came back on the final page. Must be above zero.
totalItemsnumberrequiredHow 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

TokenDefaultNotes
--sp-pagination-item-size40px

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.

import { PieChart } from "@inkorange/space-ui";

Props

PropTypeDefaultNotes
slicesPieSlice[]requiredThe slices, in order. Sort before passing — usually largest first, which is what makes a pie readable at all.
donutbooleanfalseCut the middle out, making a donut. The hole gives the total somewhere to live, which is usually worth more than the extra ink.
labelReact.ReactNode—Shown in a donut's middle, under the total.
sizenumber180How wide the circle is, in pixels. Default 180.
legendbooleantrueShow the legend. Default true: a pie without one is a colour quiz.
animatedbooleantrueThe 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-labelstring—Says what the whole shows.

Also accepts every attribute of Omit<React.HTMLAttributes<HTMLDivElement>, "onSelect"> — 276 inherited props.

PieSlice

The shape behind slices, onSliceClick.

FieldTypeRequiredNotes
labelstringrequiredThe slice's name — always drawn in the legend, never colour alone.
valuenumberrequiredIts size. Negative values are dropped: a pie has no way to show one.
colorstring—Overrides this slice's colour.

Tokens

TokenDefaultNotes
--sp-pie-chart-gap-size2px

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.

import { Popover } from "@inkorange/space-ui";

Props

PropTypeDefaultNotes
labelReactNoderequiredWhat the trigger shows. The trigger is always a Button, so this is its content — text, or an icon with iconOnly and an aria-label.
childrenReactNode—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.
openboolean—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.
defaultOpenbooleanfalseOpen 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.
panelClassNamestring—Merged onto the panel.

Also accepts every attribute of Omit<ButtonProps, "children"> — 292 inherited props.

Tokens

TokenDefaultNotes
--sp-popover-panel-max-width22rem

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.

import { Progress } from "@inkorange/space-ui";

Props

PropTypeDefaultNotes
valuenumberrequiredCurrent progress, clamped between 0 and max.
maxnumber100The value representing complete.

Also accepts every attribute of React.HTMLAttributes<HTMLDivElement> — 278 inherited props.

Tokens

TokenDefaultNotes
--sp-progress-fill-colorradial-gradient(1.2px 1.2px at 22% 35%, rgb(v…
--sp-progress-height6px
--sp-progress-track-colorlinear-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.

import { RadioGroup } from "@inkorange/space-ui";

Props

PropTypeDefaultNotes
valuestringrequiredThe selected item's value. Fully controlled.
onValueChange(v: string) => voidrequiredCalled with the newly selected value.
disabledboolean—Disables every item at once.
classNamestring—Merged onto the group wrapper.
childrenReactNode—One or more RadioGroup.Item.

Tokens

TokenDefaultNotes
--sp-radio-group-label-gap-sizecalc(var(--sp-grid-base-size) * 2)
--sp-radio-group-orb-glow-colorrgba(190, 215, 255, 0.85)
--sp-radio-group-orb-halo-colorrgba(120, 170, 255, 0.55)
--sp-radio-group-orb-highlight-color#cfe0ff
--sp-radio-group-orb-mid-color#8fb4ff
--sp-radio-group-orb-rim-colorrgba(150, 195, 255, 0.9)
--sp-radio-group-row-gap-sizecalc(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.

import { Select } from "@inkorange/space-ui";

Props

PropTypeDefaultNotes
valuestringrequiredThe selected item's value. Fully controlled.
onValueChange(v: string) => voidrequiredCalled with the newly selected value.
disabledboolean—Blocks the trigger and removes it from the tab order.
placeholderstring—Shown when nothing is selected. Counts toward the control's width, so selecting an option never resizes it.
animatedbooleantrueAmbient motion: the lit arc orbiting the rim. The glass skin itself is always applied. Default true.
childrenReactNode—One or more Select.Item. Anything else is ignored for selection but still rendered in the panel.

Also accepts every attribute of Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "value" | "children" | "onChange"> — 284 inherited props.

Tokens

TokenDefaultNotes
--sp-select-panel-color#101216
--sp-select-panel-max-widthmin(90vw, 28rem)
--sp-select-trigger-max-width20rem

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.

import { Separator } from "@inkorange/space-ui";

Props

PropTypeDefaultNotes
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<HTMLSpanElement> — 278 inherited props.

Slider

A range control: one thumb for a value, two for a span between values. Each thumb is a real <input type="range">, 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.

import { Slider } from "@inkorange/space-ui";

Props

PropTypeDefaultNotes
valuenumber[]requiredThe 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[]) => voidrequiredCalled with the whole array as a thumb moves, in the same shape it was given.
minnumberrequiredLower bound.
maxnumberrequiredUpper bound.
stepnumber1The 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 <input type="range">.
minDistancenumber0How 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.
disabledboolean—Blocks interaction and removes it from the tab order.
classNamestring—Merged onto the wrapper.
aria-labelstring—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

TokenDefaultNotes
--sp-slider-range-from-colorrgba(70, 110, 210, 0.85)
--sp-slider-thumb-glow-hover-colorrgba(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-colorrgba(160, 195, 255, 0.75)
--sp-slider-thumb-rim-hover-colorrgba(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.

import { Tabs } from "@inkorange/space-ui";

Props

PropTypeDefaultNotes
valuestringrequiredThe selected tab's value. Fully controlled — there is no default.
onValueChange(v: string) => voidrequiredCalled with the newly selected tab's value.
classNamestring—Merged onto the wrapper div, which is otherwise unstyled.
childrenReactNode—A Tabs.List and the Tabs.Contents it switches between.

Tokens

TokenDefaultNotes
--sp-tabs-active-edge-colorrgba(190, 215, 255, 0.45)
--sp-tabs-active-from-colorrgba(30, 40, 76, 0.9)
--sp-tabs-hover-from-colorrgba(24, 32, 62, 0.75)
--sp-tabs-hover-to-colorrgba(10, 14, 28, 0.45)
--sp-tabs-rest-from-colorrgba(15, 20, 40, 0.6)
--sp-tabs-rest-to-colorrgba(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.

import { Text } from "@inkorange/space-ui";

Props

PropTypeDefaultNotes
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.
htmlForstring—Id of the control this labels. Only meaningful with as="label".
childrenReactNode—The text.

Also accepts every attribute of SpacingProps and Omit<React.HTMLAttributes<HTMLElement>, "color"> — 281 inherited props.

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.

import { TextArea } from "@inkorange/space-ui";

Props

PropTypeDefaultNotes
classNamestring—Merged onto the field wrapper, not the inner textarea.
animatedbooleantrueAmbient motion on the rim. The glass skin is always applied. Default true.
styleReact.CSSProperties—Applied to the field wrapper — set width here.

Also accepts every attribute of Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, "className" | "style"> — 290 inherited props.

TextField

A single line of text the reader types. A real <input> 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.

import { TextField } from "@inkorange/space-ui";

Props

PropTypeDefaultNotes
classNamestring—Merged onto the field wrapper, not the inner input.
animatedbooleantrueAmbient motion on the rim. The glass skin is always applied. Default true.
styleReact.CSSProperties—Applied to the field wrapper — set width here.
childrenReactNode—Slots rendered inside the field, alongside the input.

Also accepts every attribute of Omit<React.InputHTMLAttributes<HTMLInputElement>, "className" | "style"> — 305 inherited props.

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.

import { Tooltip } from "@inkorange/space-ui";

Props

PropTypeDefaultNotes
labelstringrequiredThe 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.
childrenReact.ReactElementrequiredThe element the tooltip describes. Receives the hover and focus handlers, so it must accept a ref.

Icons

23 icons ship from the same entry point as React components taking the standard SVG props.