# Astralis UI: full documentation
React 19 component library on semantic design tokens. Server-first: static components are true Server Components that ship zero client JavaScript (enforced by a build gate, labeled per component in system-spec.json's client field). Precompiled, prefix-isolated CSS (value props deliver through CSS custom properties); no build tooling required in the consuming app.
---
# Installation
Install the package, import one stylesheet, wrap your app. That's the whole setup.
## Requirements
| | |
| --- | --- |
| React | `^19.1` (with `react-dom`) |
| Build tooling | None: no plugins, no preprocessors, no theme compiler |
Astralis ships **precompiled CSS**: every class any component can emit is
already in the stylesheet, generated and verified at the library's own build
time. Your bundler just serves a `.css` file.
## The short way
The [CLI](/docs/cli) does all three steps below for you. For a new project:
```bash
npx astralis-cli create my-app
```
It runs the official `create-next-app` or `create-vite` prompts, then wires
Astralis into the result: stylesheet imported, provider mounted, ready to
run. For a project you already have:
```bash
npx astralis-cli init
```
Same edits, made in place. Pass `--dry-run` to see them first.
If you'd rather do it by hand, or the CLI doesn't recognise your setup, the
three steps are below. That's all `init` is doing.
## 1. Install the package
```bash
pnpm add astralis-ui
# or: npm install astralis-ui / yarn add astralis-ui
```
## 2. Import the stylesheet (once)
```tsx
import "astralis-ui/styles.css";
```
Do this at your app's entry point (root layout in Next.js, `main.tsx` in
Vite). One import covers every component, both themes and all responsive
variants.
## 3. Wrap your app in the provider
```tsx
import { AstralisProvider } from "astralis-ui";
export function App({ children }) {
return {children};
}
```
`AstralisProvider` owns theming: it resolves light/dark (including the
`"system"` preference), persists the user's choice to `localStorage`, and,
if you pass a brand color, derives the full shade scale at runtime. Details
on the [Theming](/docs/theming) page.
## Next.js (App Router)
Import the stylesheet and mount the provider in your root layout. The
provider is a client component, but your pages stay server components:
children pass straight through.
```tsx
// app/layout.tsx
import { AstralisProvider } from "astralis-ui";
import "astralis-ui/styles.css";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
{children}
);
}
```
### Avoiding the dark-mode flash
The provider applies the `.astralis-dark` class after hydration, so a user
who prefers dark can see one light-themed frame first. To paint dark from the
very first frame, run this tiny inline script before the body renders (it
reads the same storage key the provider uses):
```tsx
const themeInit = `(function(){try{var t=localStorage.getItem("astralis-ui-theme");var d=t==="dark"||((!t||t==="system")&&window.matchMedia("(prefers-color-scheme: dark)").matches);if(d)document.documentElement.classList.add("astralis-dark");}catch(e){}})();`;
// inside , before your app:
```
This documentation site uses exactly this pattern.
## Vite / SPA
```tsx
// src/main.tsx
import { createRoot } from "react-dom/client";
import { AstralisProvider } from "astralis-ui";
import "astralis-ui/styles.css";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
,
);
```
## Using Astralis alongside your own styling
Whatever your app already uses (a utility framework, CSS Modules, plain
stylesheets), Astralis coexists with it. Every Astralis class is namespaced
under an `astralis:` prefix internally, so the library's styles can never
collide with your own, and your styling setup never needs to know Astralis
exists. You can also pass your own classes to any component via `className`;
see [Style Props](/docs/style-props) for how conflicts are resolved.
## Tree-shaking
The package publishes one ES module per component with a `sideEffects`
declaration, so bundlers drop everything you don't import. Importing a
Button ships the Button, not the library.
## Tooling
Two things worth setting up once, both covered on their own pages:
- **[The CLI](/docs/cli)**: beyond `create` and `init`, it copies
[blocks](/blocks) into your project (`npx astralis-cli add dashboard-01`) and
generates a static theme stylesheet from a brand seed.
- **[AI agents](/docs/ai-agents)**: `npx astralis-cli connect-mcp` points
your coding agent at a server that serves these docs, so it writes against
the current component API instead of guessing at one.
Next: the [Quick Start](/docs/quick-start) builds a working form in about
twenty lines.
---
# Quick Start
A working form, theming and responsive layout in about twenty lines. This
assumes Astralis is installed, via either the three
[installation](/docs/installation) steps, or one command:
```bash
npx astralis-cli create my-app # new project
npx astralis-cli init # existing one
```
## Your first component
Everything imports from one place:
```tsx
import { Button } from "astralis-ui";
;
```
Every interactive component ships styled and accessible out of the box:
hover, focus ring, keyboard behavior and dark mode are already handled.
## A real slice of UI
Here's a small signup card combining a compound component (`Card`), form
plumbing (`Field` + `Input`), layout (`VStack`) and feedback (`Alert`).
Note what's absent: no CSS file, no class soup, no wiring between the label,
help text and input: `Field` connects them (including `aria-describedby`)
automatically.
```tsx
"use client";
import { useState } from "react";
import { Alert, Box, Button, Card, Field, Input, VStack } from "astralis-ui";
export function QuickStartForm() {
const [sent, setSent] = useState(false);
return (
Create accountStart your 14-day trial.
);
}
```
## Recolor anything
Components take a `colorScheme` prop: eleven hues plus four status aliases
(`error`, `warning`, `success`, `info`), with no extra CSS shipped for any of
them:
```tsx
Active…
```
And the whole library recolors at once if you hand the provider your brand
color. Shades, hover states and a readable text color are derived at
runtime:
```tsx
```
How both of these work is the [Theming](/docs/theming) page.
## Layout with style props
Layout primitives (`Box`, `Flex`, `Stack`, `Grid`, …) expose the design
scale as typed props, and every one of them accepts a responsive map:
```tsx
MainSidebar
```
Values autocomplete in your editor, and anything off the scale is a type
error. See [Style Props](/docs/style-props) and
[Responsive Props](/docs/responsive).
## Dark mode
Already working. The provider tracks the system preference by default; to
give users a control, drop in the ready-made
[Theme Toggle](/docs/components/theme-toggle), or read and set it yourself:
```tsx
import { useTheme } from "astralis-ui";
const { resolvedTheme, setTheme } = useTheme();
setTheme(resolvedTheme === "dark" ? "light" : "dark");
```
## Don't build the page from scratch
[Blocks](/blocks) are whole sections (navbars, pricing tables, dashboard
shells) assembled from the same components. The CLI copies one into your
project as source you own:
```bash
npx astralis-cli add pricing-01
```
## Where to go next
- [Theming](/docs/theming): how tokens, dark mode and the brand color work.
- [Style Props](/docs/style-props): the full prop-based styling system.
- [CLI](/docs/cli): scaffolding, blocks, and static theme generation.
- [AI agents](/docs/ai-agents): point your coding agent at these docs so it
writes against the real API.
- Any component page in the sidebar: each pairs live examples with the
exact source that renders them.
---
# Theming
How tokens, dark mode, the brand color and `colorScheme` fit together: one
mental model, four layers.
## The mental model
Astralis components never hard-code a color. They paint with **role tokens**,
CSS variables named for a job, not a color:
```css
/* what a component actually uses */
background: var(--astralis-color-surface-base);
color: var(--astralis-color-label-base);
border-color: var(--astralis-color-stroke-base);
```
Each role points at a **primitive** (a raw shade like `gray-100`), and the
pointer is different in light and dark. Theming, all of it, is re-pointing
those variables at runtime. Nothing re-renders; the browser just repaints.
| Layer | Examples | Job |
| --- | --- | --- |
| Primitives | `gray-100`, `blue-600`, `brand-500` | Raw shades: 11 palettes × 11 steps |
| Semantic roles | `surface-base`, `label-muted`, `stroke-base` | Neutral UI jobs, light/dark aware |
| Palette roles | `blue-solid`, `blue-subtle`, `blue-ring`, … | Same 8-role vocabulary for every hue |
| Accent channel | `accent-solid`, `accent-ring`, … | A virtual palette: whatever `colorScheme` says |
The full palette and role reference lives on the [Colors](/docs/colors)
page; the raw scales on [Design Tokens](/docs/tokens).
## Dark mode
Dark mode is one class, `.astralis-dark` on ``, under which every
semantic role is re-declared with its dark value. The provider manages the
class for you:
```tsx
```
- `defaultTheme` is `"light"`, `"dark"` or `"system"` (follows the OS, live).
- The user's explicit choice persists to `localStorage` and wins on the next visit.
- Read or change it anywhere with the `useTheme` hook, or drop in the
prebuilt [Theme Toggle](/docs/components/theme-toggle).
```tsx
const { theme, resolvedTheme, setTheme } = useTheme();
// theme: "light" | "dark" | "system" resolvedTheme: "light" | "dark"
```
Because components only ever reference roles, **dark mode costs you
nothing**: anything you build from Astralis components and semantic tokens
is dark-ready automatically.
## Brand color: one hex, a whole theme
Hand the provider a single color and the library derives everything else at
runtime:
```tsx
```
Try it live:
```tsx
"use client";
import { useState } from "react";
import { Badge, Box, Button, HStack, Tag, Text, VStack, generateBrandTokens, useTheme } from "astralis-ui";
const PRESETS = [
{ name: "Default", color: undefined },
{ name: "Violet", color: "#8b5cf6" },
{ name: "Ocean", color: "#0284c7" },
{ name: "Emerald", color: "#059669" },
{ name: "Rose", color: "#e11d48" },
];
export function BrandTheming() {
const [brand, setBrand] = useState("#8b5cf6");
const { resolvedTheme } = useTheme();
return (
{PRESETS.map((preset) => (
))}
setBrand(event.target.value)}
/>
{/* The same vars the provider injects for tokens={{ brandColor }} */}
BadgeTag
One hex in, a full palette out: shades, hover states and a
readable text color are all derived at runtime.
);
}
```
What happens under the hood:
1. Your color becomes the `500` step, and ten shades (`50`–`900`) are
computed in **OKLCH**, a perceptual color space where lightness moves
without shifting the hue, so a violet stays violet at both ends instead
of washing toward grey.
2. The brand **role tokens** (`brand-solid`, `brand-subtle`, `brand-ring`, …)
are re-derived from those shades, with different recipes for light and
dark.
3. Text-on-brand (`brand-contrast`) is chosen automatically: black or white,
whichever is readable on your color.
The demo above uses the same exported function the provider uses,
`generateBrandTokens(color, resolvedTheme)`, so you can scope a brand
override to any subtree by spreading its result into a `style` prop.
## colorScheme: the accent channel
Every themeable component takes a `colorScheme` prop:
```tsx
```
Here's the trick: components are **not** styled per hue. They paint with a
single virtual palette: `accent-solid`, `accent-subtle`, `accent-ring`, and
so on. By default the accent channel points at your brand. `colorScheme`
simply adds one scope class (like `astralis-accent-teal`) that re-points all
eight accent variables at teal's role tokens for that subtree.
```tsx
import { Button, Text, VStack, HStack } from "astralis-ui";
const variants = ["solid", "subtle", "surface", "outline", "text", "link"] as const;
const schemes = [
"brand", "gray", "red", "orange", "yellow", "green",
"teal", "blue", "cyan", "purple", "pink",
] as const;
export function ButtonColorSchemes() {
return (
{variants.map((variant) => (
{variant}
{schemes.map((scheme) => (
))}
))}
);
}
```
Two consequences worth knowing:
- **Zero cost per hue.** Each scheme ships as a few lines of variable
re-binding, not another copy of every component's CSS.
- **Theme-aware for free.** Each scheme points at role tokens that already
flip for dark mode, so `colorScheme="purple"` looks right in both themes
without any extra work.
Available schemes: eleven hues (`brand`, `gray`, `red`, `orange`, `yellow`,
`green`, `teal`, `blue`, `cyan`, `purple`, `pink`) plus four status aliases
(`error`, `warning`, `success`, `info`) that follow your theme's status
colors.
## Overriding tokens yourself
Every token is a documented CSS variable, so your own stylesheet can retune
the system globally:
```css
:root {
--astralis-border-radius-lg: 0.75rem; /* chunkier corners */
--astralis-color-surface-base: #fafaf9; /* warmer page background */
}
.astralis-dark {
--astralis-color-surface-base: #0c0a09;
}
```
One caveat for **subtree** overrides: CSS variables resolve where they're
declared, so overriding a *primitive* (like `--astralis-color-brand-500`) on
a nested element won't reach components: they read *role* tokens that were
already resolved higher up. Override the role tokens themselves, or use
`generateBrandTokens` as shown above, which handles exactly this.
## Build a theme by eye
The [Theme builder](/theme-builder) tunes the brand, neutral and status
colors, radius, spacing, type scale, fonts and motion against a live canvas of
real components. It hands you a stylesheet to import after
`astralis-ui/styles.css`, or the same seed as ``
props, plus a link that reopens the theme exactly as you left it.
[`npx astralis-cli theme`](/docs/cli) writes the same stylesheet from the
command line.
---
# Colors
The palettes, the role vocabulary every hue shares, and where each is meant
to be used.
## The palettes
Ten hues plus `brand`, each with eleven steps from `50` (near white) to
`950` (near black). These are the **primitives**, the only real color
values in the system; everything else is a pointer to one of these.
```tsx
import { Box, Grid, HStack, Text, VStack } from "astralis-ui";
const HUES = [
"gray", "red", "orange", "yellow", "green",
"teal", "blue", "cyan", "purple", "pink",
] as const;
const STEPS = ["50", "100", "200", "300", "400", "500", "600", "700", "800", "900", "950"] as const;
export function PaletteGrid() {
return (
{HUES.map((hue) => (
{hue}
{STEPS.map((step) => (
))}
))}
);
}
```
`brand` is special: by default it aliases the gold palette, but it's the one
palette that can be **replaced at runtime**: pass
`tokens={{ brandColor }}` to the provider and all ten brand steps are
recomputed from your color (see [Theming](/docs/theming)).
## Roles: how components actually pick colors
Components rarely touch a raw step like `blue-600`. Every palette exposes
the same seven **roles**, and those are what variant styles are written
against:
| Role | Job | Example use |
| --- | --- | --- |
| `solid` | The strong fill | Solid button background |
| `contrast` | Text on top of `solid` | Solid button label |
| `label` | Tinted text on neutral backgrounds | Link text, subtle-button label |
| `subtle` | Faint tinted background | Badge background, hover wash |
| `muted` | A step stronger than subtle | Subtle-button hover |
| `stroke` | Tinted border | Outline button border |
| `ring` | Focus ring | Any focused control |
Two things make this vocabulary powerful:
- **Light and dark values differ per role.** In light mode `blue-subtle`
points at `blue-100`; in dark mode it re-points at a deep step. Write
against roles and dark mode is automatic.
- **Every hue answers the same questions.** Because all palettes share the
vocabulary, one set of component styles can be recolored to any hue.
That's what makes `colorScheme` possible.
## The accent channel
`accent-*` is a *virtual* palette. It has the same seven roles but
no colors of its own: it forwards to whichever real palette the nearest
`colorScheme` chose (brand by default). Components are written once against
`accent-*` and recolor through it. The full mechanism is explained on the
[Theming](/docs/theming) page.
## Neutral semantic tokens
For everyday UI structure (page backgrounds, text, borders), use the
neutral roles instead of any palette:
| Family | Tokens | Job |
| --- | --- | --- |
| Surface | `base` · `subtle` · `muted` · `raised` · `inverted` | See the ladder below |
| Label | `base` · `muted` · `subtle` · `inverted` | See the label ladder below |
| Stroke | `base` · `subtle` · `muted` · `inverted` | See the stroke ladder below |
### The surface ladder
Three neutrals, one rule each. You should never have to guess which to reach for.
| Token | When |
| --- | --- |
| `base` | The page, and any control at rest: an input, a checkbox, an active tab |
| `subtle` | Something sitting on the page but not floating: a card, a table header, an inactive item |
| `muted` | Not interactive: disabled controls (with reduced opacity), and inert parts like a progress track or slider rail |
**Hover is one step up the ladder from wherever the element rests.** A control
resting on `base` hovers to `subtle`; something already at `subtle` hovers to
`muted`. There is no separate hover token to memorise, and no component gets to
pick its own answer.
Two things the ladder does not cover. **`raised` is not a fourth neutral**: it
is identical to `base` in light mode and one step lighter in dark, where a
shadow cannot read. Floating layers only: menus, toasts, popovers, modals,
drawers, listboxes. Cards never use it. And **`inverted`** is for the few things
that must read against any surface at all: tooltips, the slider value bubble.
### The label ladder
Three tiers of text, ordered by contrast against the page.
| Token | When |
| --- | --- |
| `base` | Body copy, headings, anything you actually read |
| `muted` | Secondary text: descriptions, captions, help text |
| `subtle` | The faintest tier that is still content: placeholders, empty states, listbox group headers, calendar weekday rows |
**Every tier clears WCAG AA (4.5:1) on the page it sits on**, in both modes.
`subtle` is deliberately the floor rather than "as light as it can go": it
paints placeholder text, and a placeholder nobody can read is a bug, not a
style. If you want something genuinely decorative and unreadable, reach for
`opacity`, not a lighter label token.
One asymmetry worth knowing: dark is spaced one ramp step wider than light.
The neutral ramp has nothing between gray-400 and gray-500 on a near-black
page, so holding `muted` where light has it would have pushed `subtle` under
the AA line.
`inverted` is the fourth: text on an inverted surface, used by Tooltip and the
slider value bubble.
### The stroke ladder
Strokes work differently, and the difference is deliberate. A default
*background* is the absence of tint, so surfaces can only ever step up from
`base`. A default *border* has to be visible, so `stroke-base` sits in the
middle, with one step either side of it.
| Token | When |
| --- | --- |
| `subtle` | A dividing line inside something that is already bordered: a card header rule, a drawer footer |
| `base` | Every default border, and every control outline: cards, tables, inputs, checkboxes, panels, Separator |
| `muted` | Stronger than the default: the hover border on an outline input |
So the direction reads `subtle < base < muted`, ordered by contrast against the
page in both light and dark. Hovering an outline control still means "one step
up", exactly as it does for surfaces. It just starts from `base` rather than
ending there.
### Status colours are palettes, not neutrals
`error`, `warning`, `success` and `info` are **not** part of the three families
above. Those families are ladders measuring emphasis; a status colour carries
meaning, which is a different axis entirely. So status lives where every other
colour lives: as a palette with the same seven roles:
```jsx
{/* error-label: the text role */}
{/* success-solid: the fill role */}
{/* warning-stroke: the line role */}
```
These are the same tokens `colorScheme="error"` rebinds onto the accent
channel, so Alert, Toast and form validation all resolve to one set of values
rather than two that drift apart.
### Naming a hue picks the role that fits the property
Every palette works this way, not just the status ones. The bare hue resolves
to whichever role the CSS property actually wants (`solid` for a fill, `label`
for text, `stroke` for a line), so the common case is one word:
| | Bare form | Also available |
| --- | --- | --- |
| `bg` | `"blue"` → `blue-solid` | `blue-subtle` · `blue-muted` · `blue-50`–`blue-950` |
| `color` | `"blue"` → `blue-label` | `blue-solid` · `blue-contrast` · `blue-50`–`blue-950` |
| `borderColor` | `"blue"` → `blue-stroke` | `blue-50`–`blue-950` |
Two things worth knowing. **`gray` has no bare form**: its `solid` is
near-black in light and white in dark, which is never what `bg="gray"` would
suggest, and the neutral ladder already covers those cases. And the numbered
shades are the escape hatch, not the default: they are the only colour tokens
that do **not** re-point in dark mode, so `bg="blue-500"` freezes one mode's
decision into both.
## Using colors in your own code
Style props accept all three tiers:
```tsx
{/* neutral semantic role */}
{/* palette role */}
{/* raw primitive, last resort */}
{/* neutral label role */}
```
Prefer the highest tier that says what you mean: semantic roles adapt to
both themes on their own, palette roles adapt within their hue, and raw
steps don't adapt at all. In custom CSS, the same values are available as
variables: `var(--astralis-color-blue-subtle)`, exactly like the swatch
grid demo above does.
---
# Responsive Props
Every style prop accepts a breakpoint map: mobile-first, precompiled, zero
runtime cost.
## The syntax
Anywhere a style prop takes a token, it also takes an object keyed by
breakpoint:
```tsx
{/* scalar */}
{/* responsive map */}
```
```tsx
import { Box, Text } from "astralis-ui";
export function BoxResponsive() {
return (
Resize the window: padding and radius step up at md and lg.
);
}
```
## Breakpoints
| Key | Min-width | Typical device |
| --- | --- | --- |
| `base` | None | Everything (mobile first) |
| `sm` | 40rem / 640px | Large phones |
| `md` | 48rem / 768px | Tablets |
| `lg` | 64rem / 1024px | Laptops |
| `xl` | 80rem / 1280px | Desktops |
**Mobile-first means each key applies from that width up.** `base` is your
mobile value; `md` overrides it from 768px onward and keeps applying at `lg`
and `xl` unless you override again. You only write the breakpoints where
something *changes*:
```tsx
{/* column on phones, row from tablets up, nothing else needed */}
```
Omitting `base` is fine too: the prop's normal default fills in below your
first breakpoint.
## What's responsive
All layout style props (spacing, sizing, colors, radius, display,
position: everything on [Style Props](/docs/style-props)) plus the
layout-shaping variant props of the primitives:
```tsx
```
The common recipes:
```tsx
{/* hide on mobile, show from md */}
{/* full-width mobile, fixed sidebar on desktop */}
{/* tighter type and padding on small screens */}
```
## Container queries
Every responsive key has a container-query twin, written with an `@` prefix:
`@sm`, `@md`, `@lg`, `@xl`. Same names, same widths, but measured against the
**nearest ancestor marked `container`** instead of the browser window.
```tsx
```
The card is `lg` when *the Box* is at least 48rem wide. Drop the same card
into a narrow sidebar and it renders `sm`, on the same screen, with no extra
code. That is the promise viewport breakpoints can't keep: a component styled
by the space it actually has, wherever it lands.
Everything responsive works with `@` keys (keyword props, value props,
arbitrary values), and viewport and container keys mix freely in one map:
```tsx
{/* hidden on phones (viewport), row layout once the panel is wide (container) */}
```
Two rules to remember:
- **Something must be the container.** `@` keys measure the nearest ancestor
with the `container` prop (any layout primitive takes it). With no container
ancestor, `@` values simply never fire.
- `container` uses `container-type: inline-size`. The element sizes from its
context as usual, but note it no longer contributes its intrinsic width to
`max-content` ancestors.
## How it works (and why it's fast)
Nothing is computed in the browser. A value-bearing prop resolves to one
**fixed class per breakpoint** plus a CSS custom property carrying the value:
```html
```
The classes already exist in the stylesheet you imported: `.astralis-p`
reads `var(--astralis-p)`, and `.astralis-p-md` does the same inside a
`min-width: 48rem` media query. The class says *where* the value lands; the
variable says *what* it is. Because the value travels as a token variable
rather than being baked into a class name, the stylesheet stays small no
matter how many values exist, and theming keeps cascading through the same
tokens.
Keyword props (`display`, `position`, alignment, ...) are small closed sets
and resolve to pre-generated classes per breakpoint instead
(`astralis:hidden astralis:md:flex`). A build gate verifies every class
(fixed rules and keyword variants alike) landed in the compiled CSS.
From there it's ordinary media queries, handled natively by the browser.
Two practical consequences:
- **No resize listeners, no JS at breakpoints, no flash**: server-rendered
HTML is responsive before hydration (custom properties in `style` are
plain HTML attributes).
- **Values are unbounded.** Because only the class must exist at build time,
a breakpoint entry can be a scale token *or* any CSS value:
`p={{ base: "5vw", md: "4" }}` mixes both. See
[Arbitrary values](/docs/style-props#arbitrary-values).
---
# Style Props
The design scale as typed props: spacing, color, radius and more on every
layout primitive.
## The idea
Instead of writing CSS (or utility class strings) for everyday styling, you
pass tokens as props:
```tsx
```
Three things distinguish this from a `style` attribute:
- **Values come from the design scale first**: `p="6"` is the spacing token
`6`, `bg="subtle"` is a semantic color role. Your editor autocompletes the
tokens, so consistency is the default, not a discipline.
- **The scale is a vocabulary, not a ceiling.** When no token fits, pass the
CSS value itself (`p="37px"`, `w="calc(100vw - 200px)"`) and it rides the
same delivery. See [Arbitrary values](#arbitrary-values).
- **Every prop is responsive.** Any value can be a breakpoint map. See
[Responsive Props](/docs/responsive).
```tsx
import { Box, HStack } from "astralis-ui";
export function BoxStyleProps() {
return (
);
}
```
## Where style props live
[Box](/docs/components/box) is the host: a polymorphic `div` exposing the
full set. Every other layout primitive (**Flex, Stack, Grid, Center,
Container, AspectRatio, Float**) extends Box, so all of them accept all of
these props on top of their own. Typography components (**Text, Heading**)
carry the relevant subset.
## Paint and placement
Everything else follows one rule:
> **A component owns how it looks. Its parent owns where it sits and how big it is.**
**Paint** is how something looks on its own: `bg`, `color`, `borderColor`,
`rounded`, `shadow`, and the padding *inside* it. Higher-level components
(Button, Card, Select, Alert…) do **not** take these. Their appearance is the
design system's job, adjusted through `variant`, `size` and `colorScheme`.
Repainting one from the outside would defeat the variants it ships with.
**Placement** is how big something is and where it sits *inside its parent*:
width and height, the flex-item props, and margin. A component cannot know
these: only the composition can. So every component that can be a child
accepts them.
The test, if you're unsure which one you're reaching for:
> If I moved this component to a different page, would the prop still be right?
`variant="outline"` still is. That's paint. `w="full"` depends entirely on
what's around it. That's placement.
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `w · minW · maxW` | "0" – "96", "full", "screen", fractions, "xs" – "7xl" | None | How much horizontal room the component takes in its parent. |
| `h · minH · maxH` | "0" – "96", "full", "screen", fractions | None | The same vertically. |
| `flex · basis · grow · shrink` | "1", "auto", "initial", "none" · spacing scale | None | How the component behaves as a flex child: whether it fills, holds, or shrinks. |
| `order · alignSelf` | "first" \| "last" \| "none" · "start" \| "center" \| "end" \| "stretch" \| "baseline" | None | Where it sits among its siblings, overriding the parent's alignment for one child. |
| `m · mx · my · mt/mb/ml/mr` | "0" – "96" (spacing scale) \| "auto" | None | Space between the component and its siblings. auto is the layout tool: ml="auto" pushes a flex item to the far edge, mx="auto" centres a fixed-width block. |
Padding is paint but margin is placement, which looks inconsistent until you
say it out loud: padding is space *inside* a component, which its recipe owns;
margin is space *between* it and its siblings, which only the parent can
decide.
### One name, two meanings
`size` is the exception worth knowing. On Box it sets width **and** height. On
a component with its own scale (Card, Button, Badge), it selects that scale.
They are different props that happen to share a name, so a component with a
recipe `size` does not inherit Box's.
Container has no scale of its own, so `size` there used to fall through to
Box's and silently constrain the height. It is now a type error; use `maxW`.
```tsx
{/* error: Container has no size */}
{/* what you meant */}
```
## The prop families
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `p · px · py · pt/pb/pl/pr` | "0" – "96" (spacing scale) | None | Padding: all sides, per axis, or per edge. |
| `m · mx · my · mt/mb/ml/mr` | "0" – "96" (spacing scale) \| "auto" | None | Margin: all sides, per axis, or per edge. auto pushes a flex item to the far edge or centres a fixed-width block. |
| `w · h · size · minW/maxW · minH/maxH` | spacing scale \| "xs"–"8xl" \| fractions \| "auto" \| "full" \| "fit" \| "prose" … | None | Sizing. size sets width and height at once. |
| `bg` | "base" \| "subtle" \| "muted" \| "raised" \| "inverted" \| "{hue}" (solid) \| "{hue}-subtle" \| "{hue}-muted" \| "{hue}-50"–"{hue}-950" | None | Background from semantic roles, palette roles, or raw shades. Transparent by default. base is the page, subtle the thing sitting on it, muted a filled control; raised is for floating layers only. |
| `color` | "base" \| "muted" \| "subtle" \| "inverted" \| "{hue}" (label) \| "{hue}-solid" \| "{hue}-contrast" | None | Text color token; children inherit it. |
| `border (+ borderT/R/B/L) · borderStyle · borderColor` | "normal"–"thickest" · CSS styles · "base" \| "subtle" \| "muted" \| "{hue}" (stroke) | None | Border width, style and color tokens. Per-side widths mirror rounded: borderB is a header rule, borderT a footer one. |
| `rounded (+ roundedT/R/B/L, roundedTl/Tr/Br/Bl)` | "none" \| "2xs"–"4xl" \| "full" | None | Corner radius: all corners, per side, or per corner. |
| `display` | "block" \| "flex" \| "grid" \| "hidden" \| … | None | CSS display. |
| `position · inset · top/right/bottom/left · zIndex` | position tokens + offset scale | None | Positioning. |
| `shadow` | "none" \| "xs" \| "sm" \| "md" \| "lg" \| "xl" \| "2xl" \| "inner" | None | Box shadow token. |
| `opacity · overflow(X/Y) · cursor · pointerEvents · aspectRatio` | token maps | None | Misc utilities, all typed. |
| `basis · flex · grow · shrink · order · alignSelf` | sizing scale · "1" \| "auto" \| "initial" \| "none" · boolean \| "0" \| "1" · "1"–"12" · "auto" \| "start" \| "center" \| "end" \| "stretch" \| "baseline" | None | How the element behaves as a child of a flex or grid parent. On Box, so any primitive can size itself without a Flex.Item wrapper. |
| `container` | boolean | `false` | Makes this element the query container that descendants' @sm–@xl responsive keys measure against, instead of the viewport. |
| `as` | ElementType | `"div"` | Element or component to render; HTML props follow it type-safely. |
## Arbitrary values
Every **value** prop (spacing, sizing, gap, radius, offsets, colors, shadow,
opacity: everything that isn't a closed keyword set) accepts any CSS value
alongside its tokens. A string that isn't a token is delivered through the
same custom property the token would have used:
```tsx
{/* raw length */}
{/* calc, clamp, min, max */}
{/* mix raw and tokens per breakpoint */}
{/* states take them too */}
```
Two things to know:
- **Tokens win.** If the string is a token, you get the token's variable:
`p="4"` is always `var(--astralis-spacing-4)`, never the literal `4`.
- **Units are on you.** A bare number like `p="37"` isn't valid CSS for a
length, so Astralis warns in development and passes it through unchanged.
Write `p="37px"` (or use a token).
**Keyword props stay closed.** `display`, alignment, overflow and friends are
finite CSS vocabularies backed by build-time classes. An unknown value there
resolves to nothing, exactly as before.
## Escape hatches
Arbitrary values cover most "outside the scale" needs. For everything else,
drop down a level, in this order:
**1. `style`**: for dynamic values and properties that aren't props. Value
props deliver through CSS custom properties, and your `style` always wins
over them:
```tsx
{/* one-off beats the prop */}
```
Reach for the token variables inside `style` to stay on the scale:
```tsx
```
**2. `className`**: every component forwards it. Astralis's internal
classes are prefixed (`astralis:*`), so your own utility classes, whatever
generates them, can't collide. Note that Astralis ships utility classes
only for **keyword** props (`display`, alignment, overflow, ...); since the
var-channel migration there is no per-value class like `astralis:p-4` to
reach for. Value styling belongs to the props or to `style`.
```tsx
```
**3. Plain CSS**: Astralis renders ordinary DOM elements, and every design
token is a documented CSS variable, so your stylesheets can target
components and stay on the scale:
```css
.sidebar {
padding: var(--astralis-spacing-6);
background: var(--astralis-color-surface-subtle);
}
```
## Why tokens?
Astralis ships **precompiled CSS**. Value props resolve to a fixed rule per
prop that reads a custom property (see [Responsive Props](/docs/responsive)
for the mechanics), and keyword props resolve to pre-generated classes. A
build-time coverage gate verifies every rule, class, and token value has CSS
behind it. Because values ride custom properties rather than classes, they
don't need to exist at build time (which is what makes arbitrary values
free), and everything stays zero-runtime: no style engine executes in the
browser.
---
# Design Tokens
The complete reference of every scale in the system. Each token is a CSS
variable (prefixed `--astralis-`) *and* a typed prop value. The same name
works in both worlds:
```tsx
{/* as a prop */}
```
```css
padding: var(--astralis-spacing-6); /* as a variable */
```
Color tokens have their own page. See [Colors](/docs/colors).
## Spacing
Used by padding, margin and gap props. A 4px-based scale: the token number
× 0.25rem, with half steps at the small end.
| Tokens | Values |
| --- | --- |
| `0.5` – `4.5` (half steps) | 0.125rem – 1.125rem (2px – 18px) |
| `1` – `12` (every integer) | 0.25rem – 3rem (4px – 48px) |
| `14` `16` `20` `24` `28` `32` `36` `40` `44` `48` `52` `56` `60` `64` `72` `80` `96` | 3.5rem – 24rem (56px – 384px) |
Variables: `--astralis-spacing-{token}`.
## Sizing
Used by `w`, `h`, `size`, `minW`/`maxW`, `minH`/`maxH`. Four vocabularies,
all valid anywhere a size is accepted:
| Vocabulary | Tokens | Values |
| --- | --- | --- |
| Numeric | same numbers as spacing | `4` = 1rem … `96` = 24rem |
| T-shirt | `3xs` `2xs` `xs` `sm` `md` `lg` `xl` `2xl` – `8xl` | 14rem, 16rem, 20rem, 24rem, 28rem, 32rem, 36rem, 42rem – 90rem |
| Fractions | `1/2` `1/3` `2/3` `1/4` `3/4` fifths, sixths, twelfths | percentages |
| Keywords | `full` `fit` `min` `max` `auto` | `100%`, `fit-content`, `min-content`, `max-content`, `auto` |
Plus viewport units: `vw` `vh` and the small/large/dynamic variants
(`dvh` `svh` `lvh` `dvw` `svw` `lvw`). Variables: `--astralis-size-{token}`.
## Typography
| Scale | Tokens |
| --- | --- |
| Font size | `3xs` (0.5rem) `2xs` `xs` `sm` `md` (1rem) `lg` `xl` `2xl` `3xl` `4xl` `5xl` `6xl` `7xl` `8xl` `9xl` (8rem) |
| Weight | `thin` `extralight` `light` `normal` `medium` `semibold` `bold` `extrabold` `black` |
| Letter spacing | `tighter` `tight` `normal` `wide` `wider` `widest` |
| Line height | `none` (1) `tight` (1.25) `snug` (1.375) `normal` (1.5) `relaxed` (1.625) `loose` (2) |
Font families are theme hooks: set `--astralis-font-heading` and
`--astralis-font-body` in your CSS to give the whole library your
typefaces. The [Text](/docs/components/text) and
[Heading](/docs/components/heading) components pick them up automatically.
## Radius
Used by `rounded` and its per-side/per-corner variants.
| Token | `none` | `2xs` | `xs` | `sm` | `md` | `lg` | `xl` | `2xl` | `3xl` | `4xl` | `full` |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| Value | 0 | 1px | 2px | 4px | 6px | 8px | 12px | 16px | 24px | 32px | 9999px |
Variables: `--astralis-border-radius-{token}`.
## Borders
| Scale | Tokens |
| --- | --- |
| Width (`border`) | `normal` (1px) `moderate` (2px) `thick` (4px) `thicker` (8px) `thickest` (12px) |
| Style (`borderStyle`) | `solid` `dashed` `dotted` `double` `hidden` `none` |
Border *colors* come from the stroke tokens on the [Colors](/docs/colors)
page.
## Shadows
Used by the `shadow` prop: `none` `xs` `sm` `md` `lg` `xl` `2xl` `inner`.
Shadows are theme-aware: dark mode automatically swaps in deeper values so
elevation stays legible on dark surfaces. Variables:
`--astralis-shadow-{token}`.
## Motion
Durations used by every component transition:
| Token | `fastest` | `faster` | `fast` | `moderate` | `slow` | `slower` | `slowest` |
| --- | --- | --- | --- | --- | --- | --- | --- |
| Value | 50ms | 100ms | 150ms | 200ms | 300ms | 400ms | 500ms |
Variables: `--astralis-duration-{token}`. Components honor
`prefers-reduced-motion`: continuous animations (Marquee, Carousel
autoplay) pause for users who ask for less motion.
## Breakpoints
The responsive-prop keys: `sm` 640px · `md` 768px · `lg` 1024px ·
`xl` 1280px. Details on [Responsive Props](/docs/responsive).
## Overriding tokens
Every token above is a plain CSS variable, so retuning the system globally
is one declaration (no build step, no config file):
```css
:root {
--astralis-border-radius-lg: 0.75rem; /* every "lg" corner in the app */
--astralis-font-heading: "Fraunces", serif;
--astralis-duration-moderate: 150ms; /* snappier default transitions */
}
```
For color-token overrides (which have a light/dark subtlety), see
[Theming](/docs/theming#overriding-tokens-yourself).
---
# CLI
Scaffold a project, add blocks, generate a static brand theme, validate code
against the design system, and connect AI agents with the `astralis`
command-line tool.
## Running it
Every command runs through `npx`, so there's nothing to install:
```bash
npx astralis-cli [options]
```
Or install it once and call `astralis` directly:
```bash
pnpm add -g astralis-cli
astralis
```
It needs Node `^22.18` or `>=24.11`. Run `astralis --help` for the command
list, or `astralis --help` for a command's options. The option
tables on this page are generated from the same source as `--help`.
## `create`: start a new project
Scaffolds a fresh app with the official `create-next-app` / `create-vite`
prompts, then wires Astralis into it.
```bash
npx astralis-cli create my-app
```
It asks which framework you want (Next.js, or React via Vite), then hands
off to that scaffolder's own prompts, so you get the official setup rather
than a copy of it that drifts. When it finishes, the stylesheet is imported,
the provider is mounted, and a welcome page you can delete replaces the
scaffolder's demo. Run `npm run dev` (or your package manager's equivalent)
inside the new folder to start it.
| Option | |
| --- | --- |
| `--framework ` | Skip the framework prompt. |
| `--no-starter` | Keep the scaffolder's demo page instead of the Astralis welcome screen. |
| `--no-devtools` | Leave out the Astralis DevTools overlay, which new projects get in development. |
With `--yes` it asks nothing: pass `--framework`, and it tells the scaffolder
to take its defaults too.
## `init`: add Astralis to an existing project
Wires `astralis-ui` into a project you already have (Next.js or Vite): installs
the package, adds the `astralis-ui/styles.css` import, and mounts
`` at your app's entry point: the same [manual
steps](/docs/installation), done for you.
```bash
npx astralis-cli init
```
| Option | |
| --- | --- |
| `--dry-run` | Show the changes without writing anything. |
| `--ci` | Also check every pull request: pin astralis-cli, add a validate script and write a GitHub Actions workflow. |
| `--force` | With --ci, replace an existing astralis-validate.yml. |
| `--devtools` | Also add the Astralis DevTools overlay (development only): install astralis-devtools and wire it into the root layout or vite.config. |
It reads your entry file rather than pattern-matching it, so an import that is
already there (however it's quoted or wrapped) isn't added twice, and it never
writes a file that no longer parses. If your entry file is a shape it can't
edit safely, it prints the manual steps instead. Running it twice is safe.
With `--ci` it also sets up a check on every pull request: it pins
`astralis-cli` as a devDependency, adds a `validate` script and writes a GitHub
Actions workflow. [Validate in CI](/docs/validate-in-ci) covers that, and a
pre-commit hook.
## `add`: copy a block into your project
Writes a [block](/blocks)'s source into your codebase. Blocks aren't imported
from a package: the file lands in your repo and you own it, so editing it is
editing your own code.
```bash
npx astralis-cli add dashboard-01
```
Name as many as you like. To see what's available without opening the
browser:
```bash
npx astralis-cli add --list
```
| Option | |
| --- | --- |
| `--list` | Print every available block, by category, and exit. |
| `--dir ` | Where to write. Default: src/components/blocks, or components/blocks without a src/. |
| `--overwrite` | Replace existing files without asking. |
| `--dry-run` | Show what would be written; write nothing. |
| `--registry ` | Read blocks from a different registry host. |
This command is deliberately small compared with the equivalent in other
libraries, and the reason is architectural: a block imports only
`astralis-ui`, React, or a sibling file (the registry builder rejects
anything else), and the stylesheet is precompiled. So there are no import
paths to rewrite, no dependency graph between blocks, and no Tailwind config
to merge. It fetches text and writes files, and refuses any file path that
would land outside the target folder.
## `theme`: generate a static theme
Produces a plain CSS stylesheet from a brand seed, for when you want your theme
baked into a file rather than derived at runtime by the provider. See
[Theming](/docs/theming) for how the two approaches compare, or build a seed
visually in the [theme builder](/theme-builder).
```bash
npx astralis-cli theme "#6d3bf5"
```
| Option | |
| --- | --- |
| `--brand ` | Brand hue, e.g. "#8b5cf6". A bare hex argument means the same. |
| `--gray ` | Neutral hue: every surface, label and border. |
| `--error ` | Seeds the error palette. Default: red. |
| `--warning ` | Seeds the warning palette. Default: orange. |
| `--success ` | Seeds the success palette. Default: green. |
| `--info ` | Seeds the info palette. Default: blue. |
| `--font-heading ` | Heading font stack. |
| `--font-body ` | Body font stack. |
| `--font-mono ` | Monospace font stack. |
| `--radius ` | Border-radius multiplier. Default: 1. |
| `--spacing ` | Spacing multiplier: the density dial. Default: 1. |
| `--font-scale ` | Font-size multiplier. Default: 1. |
| `--motion ` | Duration multiplier; 0 turns transitions off. Default: 1. |
| `--out ` | Where to write the stylesheet. Default: src/astralis-theme.css, or astralis-theme.css without a src/. |
| `--force` | Overwrite an existing file. |
| `--no-import` | Write the file but leave the entry file alone. |
| `--strict-contrast` | Exit 1, writing nothing, if any promised pairing fails WCAG AA. |
It uses your project's own `astralis-ui` for the colour maths, so the file
matches the version it's loaded next to, and imports the file into your entry
point after the library stylesheet. Before writing, it checks every text and
fill pairing the theme promises against WCAG AA in light and dark mode;
failures are warnings unless you pass `--strict-contrast`.
## `validate`: check code against the design system
Checks your TSX and JSX against the machine-readable spec of the `astralis-ui`
version you have installed. It catches what type-checks and still fails:
components and compound parts that don't exist, prop values outside their
token sets, `astralis:*` classes missing from the compiled CSS (they render as
nothing), undeclared token variables, illegal breakpoint and state keys,
compound parts outside their root, Tabs triggers with no panel, and
accessibility mistakes such as misspelled `aria-*` attributes, invalid roles,
and images with no `alt`.
```bash
npx astralis-cli validate # the current directory
npx astralis-cli validate src/app # a folder, or single files
npx astralis-cli validate --json # one JSON report, for CI and tools
```
| Option | |
| --- | --- |
| `--format ` | How to report: text for people, json for tools, github for inline pull-request annotations. Default: github inside GitHub Actions, text elsewhere. |
| `--json` | Same as --format json. |
| `--spec ` | Validate against this system-spec.json. Default: the installed astralis-ui's. |
| `--strict-tokens` | Also warn on raw colors that bypass the tokens. |
It skips `node_modules`, `.git`, `.next`, `dist`, `build` and `out`. Each
finding names the file, line and column, a rule code, and, where there's a
closed set, the nearest valid value.
### Exit codes
| Code | Meaning |
| --- | --- |
| `0` | No errors. Warnings alone never fail the run. |
| `1` | At least one error, or the run couldn't start: no spec found, no `.tsx`/`.jsx` files, a path that doesn't exist. |
### JSON output
`--json` prints a single object. `results` lists only files with findings;
every finding has the same five fields.
```json
{
"spec": { "name": "astralis-ui", "version": "0.8.0", "specVersion": 3 },
"files": 12,
"errors": 1,
"warnings": 0,
"results": [
{
"file": "src/app/page.tsx",
"errors": [
{
"code": "invalid-recipe-value",
"file": "src/app/page.tsx",
"line": 8,
"column": 15,
"message": "Button: \"primary\" is not a variant value (valid: link, text, subtle, solid, surface, outline)"
}
],
"warnings": []
}
]
}
```
### Raw colours: `--strict-tokens`
Arbitrary values are a feature: `bg="#ff0000"` is valid CSS and renders. But
a raw colour won't follow the theme or dark mode. `--strict-tokens` adds an
`off-token-color` warning for each one, as a drift check, without failing the
run.
To run it on every pull request and before each commit, see [Validate in
CI](/docs/validate-in-ci).
The same validator runs inside the [MCP server](/docs/ai-agents) as the
`validate_code` tool, so an AI agent checks its own output against the rules
you run in CI.
## `report`: report a problem to the maintainers
Builds an issue snapshot from the files that show the problem and sends it to
[the Astralis issue platform](/issues) as a draft, where you sign in with
GitHub, check it and submit it. The snapshot records the `astralis-ui` components in those files
with their literal props, the validator's findings for them, and the versions
of `astralis-ui`, your framework and React. It is the same snapshot the
[DevTools](/docs/devtools) build from a page.
```bash
npx astralis-cli report app/page.tsx
npx astralis-cli report src/App.tsx --title "Card loses its border" --out snapshot.json
```
| Option | |
| --- | --- |
| `--title ` | The report's title. Asked for when omitted; required with --yes. |
| `--happened ` | What happened. |
| `--expected ` | What you expected instead. |
| `--include-text` | Keep the text in your JSX; by default it is replaced by placeholders. |
| `--no-findings` | Leave out the validator's findings for these files. |
| `--out ` | Write the snapshot to a file instead of sending it. |
| `--dry-run` | Print the snapshot and send nothing. |
| `--spec ` | Build against this system-spec.json. Default: the installed astralis-ui's. |
It prints the exact JSON first and sends nothing until you confirm. Only props
the spec describes are recorded, and only literal values: a value computed at
run time, a link, an image source or an event handler never is. Text in your
JSX becomes a placeholder unless you pass `--include-text`.
## `connect-mcp`: connect an AI coding agent
Connects one AI client to the Astralis [MCP server](/docs/ai-agents) so its
agent reads your current component docs instead of guessing. It prompts for the
client, tells you exactly what it will do, and, once you confirm, does it.
```bash
npx astralis-cli connect-mcp
```
| Option | |
| --- | --- |
| `--client ` | Skip the prompt and configure this client. |
For Claude Code and Codex it runs the client's own `mcp add` command; for
Cursor, Claude Desktop, and Antigravity it writes the client's JSON config
(backing up any existing one first, never over an earlier backup). Full
details on the [AI Agents](/docs/ai-agents) page.
## Global options
Every command takes these, and `astralis --version` prints the CLI's version.
| Option | |
| --- | --- |
| `-y, --yes` | Never prompt; take the default answer. Replacing files still needs --overwrite or --force. |
| `-h, --help` | Show this command's options. |
| `--debug` | Print the stack trace if something fails unexpectedly. |
Without a terminal (in CI, say), a command that needs an answer stops and
tells you which flag supplies it, rather than guessing.
---
# Validate in CI
Run `astralis validate` before code is committed and on every pull request, so a
design-system mistake cannot be merged.
## Two gates
A team's workflow puts checks in front of version control in two places:
- **A pre-commit hook** runs on your machine, on the files you are committing,
and stops a bad commit before it is made. It is fast feedback, but it can be
skipped with `git commit --no-verify`.
- **A required check on the pull request** runs in CI on every change. It
cannot be skipped: once branch protection marks it as required, nothing is
merged until it passes.
Use the hook for speed and the CI check as the authority. Both run the same
command, so they never disagree.
The validator exits with status `1` only when it finds **errors**. Warnings,
such as a raw colour under `--strict-tokens`, are reported without failing the
run. It parses your code and never executes it.
## Set up CI in one command
From your project's root:
```bash
npx astralis-cli init --ci
```
On top of what [`init`](/docs/cli#init-add-astralis-to-an-existing-project)
already does, `--ci`:
1. installs `astralis-cli` as a devDependency, so CI and your hook run the
version you tested with, not whatever is newest;
2. adds a `"validate": "astralis validate"` script to `package.json` (or
`"astralis:validate"`, if you already have a `validate` script that does
something else);
3. writes `.github/workflows/astralis-validate.yml` for your package manager:
npm, pnpm, yarn or bun, read from your lockfile.
Running it again changes nothing. A workflow you have edited is never replaced
unless you pass `--force`.
### The workflow
For an npm project it writes exactly this. The pnpm, yarn and bun versions add
their own setup step and install command.
```yaml
# Checks every pull request against the astralis-ui design system; written by
# `astralis init --ci`. Make "Astralis validate" a required status check so a
# failing run blocks the merge: GitHub → Settings → Rules (or Branches).
name: Astralis validate
on:
pull_request:
push:
branches: [main, master]
permissions:
contents: read
jobs:
validate:
name: Astralis validate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run validate
```
Inside GitHub Actions the validator reports in GitHub's annotation format, so
each finding appears on the changed line of the pull request, with its rule
code. If your project sits in a subfolder of the repository, `init --ci` puts
the workflow at the repository root and runs it in your folder, and annotations
still point at the right file.
### Make the check required
The workflow reports; branch protection is what blocks. Push the workflow once
so GitHub has seen the check, then:
1. Open the repository's **Settings → Rules → Rulesets** (or **Settings →
Branches** for a classic branch protection rule).
2. Target your default branch.
3. Turn on **Require status checks to pass** and add **Astralis validate**.
From then on a pull request with a validator error cannot be merged.
## Add a pre-commit hook
The hook validates only the files being committed, using
[husky](https://typicode.github.io/husky/) to install the hook and
[lint-staged](https://github.com/lint-staged/lint-staged) to pass it the staged
files. It needs `astralis-cli` in your devDependencies; `init --ci` adds it, or
run `npm install -D astralis-cli`.
```bash
npm install -D husky lint-staged
npx husky init
```
Replace the contents of `.husky/pre-commit` with:
```bash
npx lint-staged
```
And add to `package.json`:
```json
{
"lint-staged": {
"*.{tsx,jsx}": "astralis validate"
}
}
```
Now a commit that stages a `.tsx` or `.jsx` file with an error is refused, with
the finding printed in the terminal. A commit that stages no `.tsx` or `.jsx`
files skips the check entirely, and warnings never block a commit.
## Other CI systems
Any CI system can run the same command. It needs Node `^22.18` or `>=24.11`, and
it fails the job through the exit code:
```bash
npx astralis-cli validate
```
With `astralis-cli` in your devDependencies, `npx` runs that pinned copy;
without it, `npx` downloads the latest release on every run. For a
machine-readable result (to post a comment, or to fail on warnings as well),
use `--json`, which prints a single report in [this
shape](/docs/cli#json-output).
---
# DevTools
A development overlay for your app. A button in the corner of the page opens
three tabs: **Inspect** tells you what any Astralis component on the page is,
**Findings** shows the validator's findings for your project, live, as you
save, and **Report** sends a problem to the Astralis maintainers with the
evidence they need to reproduce it.
It runs in development only. A production build contains none of it.
## Why a UI library needs its own
React DevTools shows the component tree, but it can only show what hydrates in
the browser, and many Astralis components never do: 91 of the 238 components
and parts in the system spec are Server Components that ship no JavaScript. It also knows nothing about your
design system, such as which props a component was configured with, whether
those values are valid, or where the component is documented.
The Astralis DevTools read the library itself. In development every component
names itself on the element it renders, and a bridge in your dev server runs
the same validator as [`astralis validate`](/docs/cli#validate-check-code-against-the-design-system)
and [CI](/docs/validate-in-ci).
## Set up
From your project's root:
```bash
npx astralis-cli init --devtools
```
It installs `astralis-devtools` as a devDependency and wires it in:
- **Next.js:** `` goes at the end of `` in your root
layout, and the bridge's route is written to
`app/api/astralis-devtools/[...path]/route.ts`.
- **Vite:** `astralisDevtools()` is added to the `plugins` of your
`vite.config`.
Projects made with `npm create astralis` have the DevTools already; pass
`--no-devtools` to leave them out. Running `init --devtools` again changes
nothing.
### By hand
For **Next.js**, add the component to the root layout:
```tsx
import { AstralisDevTools } from "astralis-devtools/next";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
{children}
);
}
```
Then create `app/api/astralis-devtools/[...path]/route.ts`:
```ts
export { GET, POST } from "astralis-devtools/next/route";
```
For **Vite**, add the plugin:
```ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { astralisDevtools } from "astralis-devtools/vite";
export default defineConfig({
plugins: [react(), astralisDevtools()],
});
```
## Inspect
Press **Pick a component**, hover the page, and click. Clicks while picking go
to the DevTools, not to your app, so a link does not navigate and a trigger
does not open its menu. Press `Esc` to stop.
For the component you pick you see:
- **Its name,** as you would write it: `Card.Body`, not `div`.
- **The props it was given:** variant, size, colour scheme and style props,
including responsive values such as `p={{ base: "4", md: "8" }}`. Children,
text, links and event handlers are never shown.
- **How it reaches the browser,** from the system spec:
| Label | Meaning |
|---|---|
| Server Component | Renders on the server and ships no library JavaScript |
| Server shell, client island | Renders on the server; a small interactive part hydrates |
| Client Component | Hydrates in the browser to be interactive |
A Vite app renders everything in the browser, so there the label tells you
what the component would ship in a server-rendered app.
- **A link to its documentation.**
- **What it sits inside:** the Astralis components around it, each one click
away.
When one component renders through another onto the same element, you see the
whole chain. A `Modal.Trigger` wrapping a `Button` shows as
`Modal.Trigger → Button`, with each one's props.
## Findings
The Findings tab lists every error and warning `astralis validate` would report
for your project, with file, line and rule. It updates as you save, usually in
well under a second. The badge on the button counts the errors, or the warnings
when there are no errors.
On Next.js, the files that render the page you are looking at (its `page`
file and every `layout` and `template` above it) are listed first, under **This
page**.
## Report
When something in Astralis looks wrong, pick the component it is in and press
**Report a problem here**, or open the **Report** tab. Write a title and, if you
like, what happened and what you expected. The tab builds an issue snapshot:
- the Astralis components under the one you picked, each with the props it was
given (only those the spec describes; never links, image sources or event
handlers);
- the versions of `astralis-ui`, your framework and React, your browser and
window size, and whether the page is light or dark;
- the page's path, without its query string;
- the validator's findings for the files that render the page (on Vite, for the
project), unless you untick them.
Page text is replaced by placeholders unless you tick **Include page text**.
Console errors from this session and a screenshot are left out unless you add
them.
Below the form is **exactly** the JSON that would be sent, updated as you type.
Nothing leaves your machine until you press **Send**; the snapshot then goes to
[Issues & Contributions](/issues), the Astralis issue platform, as a private
draft, and its page opens so you can sign in with GitHub and submit it. A draft
nobody submits is deleted after 24 hours. **Copy JSON** and **Download** keep
the snapshot without sending it.
From the terminal, [`astralis report`](/docs/cli#report-report-a-problem-to-the-maintainers)
builds the same snapshot from your source files.
The snapshot's component tree is also a reproduction: the platform turns it
into a small `repro.tsx` and runs the validator on it again. Before you submit,
it compares the report with open issues: one with the same component, the same
rule codes in its findings and the same minor version of `astralis-ui` is
offered as the likely duplicate, and adding your report to it raises its report
count instead of opening a second issue.
## How it works
In development, every astralis-ui component adds two attributes to the element
it renders:
```html
```
They are part of the server-rendered HTML, which is how Server Components can
be inspected. In production the attributes are never rendered: the library's
production HTML is byte-for-byte what it would be without the DevTools, and a
test holds it to that for every component.
The bridge runs inside your dev server. On Next.js that is the route handler
above; on Vite it is dev-server middleware, fed by Vite's own file watcher. It
validates your `.tsx` and `.jsx` files against the installed astralis-ui's
[system spec](/docs/ai-agents), re-validates each file when you save it, and
streams the findings to the overlay.
## Privacy and security
- **Nothing leaves your machine unless you send it.** The overlay talks only to
your own dev server, and there is no telemetry or background request. The one
request that leaves is a report you have read and pressed **Send** on.
- **The bridge answers this machine only.** A request from another device, or
from a page on another site, is refused, even when the dev server is open to
your network (`next dev -H 0.0.0.0`).
- **Nothing runs in production.** In a production build the Next.js route
answers `404` without loading the bridge, and the Vite plugin applies only to
`vite dev`.
---
# AI Agents
An MCP server that feeds coding agents Astralis's current component APIs, props,
and theming, instead of guesses.
## The problem it solves
Ask an AI agent to write Astralis code and it may invent props, guess
variants, and reach for APIs that don't exist, working from stale or
second-hand knowledge instead of the real component contracts. The
`astralis-mcp` server closes that gap: it exposes the **live** documentation
over the [Model Context Protocol](https://modelcontextprotocol.io), so the agent
reads the same component docs you do (current props tables, demo source, and
theming guides) at the moment it writes code.
## Setup
The fastest path is the [CLI](/docs/cli), which walks you through connecting one
client:
```bash
npx astralis-cli connect-mcp
```
It supports Claude Code, Codex, Cursor, Claude Desktop, and Antigravity, and
configures the one you pick: running its `mcp add` command, or writing its JSON
config with your existing config backed up first.
### Configure a client by hand
Claude Code (and Codex) take an `mcp add` command directly:
```bash
claude mcp add astralis -- npx -y astralis-mcp
```
Any other MCP client takes the same server entry in its config file:
```json
{
"mcpServers": {
"astralis": {
"command": "npx",
"args": ["-y", "astralis-mcp"]
}
}
}
```
## What the agent gets
The server exposes eight tools:
| Tool | Returns |
| --- | --- |
| `list_components` | Every docs page (components and guides) with slug, title, description, section, and kind. The agent calls this first to discover what exists. |
| `get_component` | Full docs for one component: usage, the complete props table, accessibility notes, and runnable demo source. |
| `get_guide` | One guide page (installation, theming, tokens, and the rest) as markdown. |
| `search_docs` | Full-text search across all documentation, with a snippet around each hit. |
| `get_theming` | The complete theming reference: token system, dark mode, runtime brand color, the accent channel, and every design-token scale. |
| `list_blocks` | Every block (prebuilt page sections) with id, description, category, and the components it composes. |
| `get_block` | One block's metadata plus the complete source of its files, ready to write into a project. |
| `validate_code` | A verdict on Astralis TSX the agent wrote, checked against the design system's machine-readable spec: unknown components or parts, off-token values, invalid recipe values (`variant="primary"` is a Chakra habit, not a Button variant), dead `astralis:*` classes, broken compound anatomy, a11y mistakes. Errors name the valid alternatives, so the agent fixes its own code and validates again before showing it to you. |
The last one is the difference between retrieval and verification: the other
tools help the agent *write* against the system; `validate_code` proves what
it wrote is *inside* the system. Validation is pure computation: it runs
locally in the server process, uses no model, and prefers the spec of the
astralis-ui version installed in your project. It is the same validator as
[`astralis validate`](/docs/cli#validate-check-code-against-the-design-system),
so the rules the agent checks against are the ones you run in CI.
The spec itself (`dist/system-spec.json` in the installed package, or
[/system-spec.json](https://astralis-ui.com/system-spec.json) on this
site) also records each component's `client` field (`none`, `leaf`, or
`required`), classified from the shipped module graph at build time. No tool
returns it yet, but an agent that reads the spec can check whether a component
ships client JavaScript before putting it in a Server Component.
## How it stays current
The server holds no copy of the docs. It reads them from this site's
machine-readable endpoints, the same single pipeline that renders the pages you
are reading now, so its answers can never drift from what's published. Ship a
doc update and every connected agent sees it. If the site can't be reached,
every tool returns an error that says so, never an answer the agent might
trust, such as a real component reported as unknown.
When you're working on unpublished docs, point the server at a local build:
```bash
ASTRALIS_DOCS_URL=http://localhost:3000
```
## Browsing agents
An agent that browses the web rather than speaking MCP can read the docs
directly: [llms.txt](https://astralis-ui.com/llms.txt) is the index of
every page, and [llms-full.txt](https://astralis-ui.com/llms-full.txt)
is the entire documentation inlined into one file.
---
# Button
A polymorphic button with six variants, eleven color schemes, icon slots and built-in loading states.
```tsx
"use client";
import { Button, Icon, HStack } from "astralis-ui";
import { Sparkles } from "lucide-react";
export function ButtonDemo() {
return (
}>Get started
);
}
```
## Import
```tsx
import { Button } from "astralis-ui";
```
## Usage
The default button is `solid` in the brand color at size `md`. Every visual
decision (variant, hue, size, radius) is one prop away.
```tsx
import { Button, HStack } from "astralis-ui";
export function ButtonVariants() {
return (
);
}
```
- **solid**: the signature fill. Use it for the single primary action on a screen.
- **subtle**: a tinted fill with no border, for secondary actions.
- **surface**: the bordered sibling of `subtle`.
- **outline**: border only; fills with a faint tint on hover.
- **text**: no chrome until hover (a ghost button).
- **link**: an inline-link affordance with an underline on hover.
## Color schemes
Every variant paints through the accent channel, so a single `colorScheme`
prop recolors any button, including [runtime brand colors](/docs/theming).
Use `gray` for neutral actions and `red` for destructive ones. The full
matrix below shows all six variants across all fifteen schemes.
```tsx
import { Button, Text, VStack, HStack } from "astralis-ui";
const variants = ["solid", "subtle", "surface", "outline", "text", "link"] as const;
const schemes = [
"brand", "gray", "red", "orange", "yellow", "green",
"teal", "blue", "cyan", "purple", "pink",
] as const;
export function ButtonColorSchemes() {
return (
{variants.map((variant) => (
{variant}
{schemes.map((scheme) => (
))}
))}
);
}
```
## Sizes
Five sizes, from compact toolbars to hero calls-to-action. Height, padding,
font size and icon gap scale together.
```tsx
import { Button, HStack } from "astralis-ui";
export function ButtonSizes() {
return (
);
}
```
## Rounded
The corner radius is independent of size: go from sharp to pill with the
`rounded` prop.
```tsx
import { Button, HStack } from "astralis-ui";
export function ButtonRounded() {
return (
);
}
```
## Icons
Pass any element to `leftIcon` or `rightIcon`. Omit the label and the button
becomes a perfect square. Icon-only buttons **must** carry an `aria-label`
(the library warns in development if one is missing).
```tsx
"use client";
import { Button, Icon, HStack } from "astralis-ui";
import { Search, ChevronDown, Settings, Trash2 } from "lucide-react";
export function ButtonIcons() {
return (
}>Search
}>
Open menu
{/* Icon-only buttons need an aria-label for assistive tech. */}
} aria-label="Settings" />
}
aria-label="Delete"
/>
);
}
```
## Loading state
`loading` swaps in a spinner and blocks interaction without a layout shift.
`loadingText` replaces the label while pending, and `loaderPlacement` moves
the spinner to either side. Bring your own spinner via `loader`.
```tsx
"use client";
import { useState } from "react";
import { Button, HStack } from "astralis-ui";
export function ButtonLoading() {
const [saving, setSaving] = useState(false);
const save = () => {
setSaving(true);
setTimeout(() => setSaving(false), 2000);
};
return (
);
}
```
## Full width
`fullWidth` stretches the button to its container, typical for checkout
flows and dialog footers.
```tsx
"use client";
import { Button, Icon, VStack } from "astralis-ui";
import { CreditCard } from "lucide-react";
export function ButtonFullWidth() {
return (
}>
Complete checkout
);
}
```
## Rendering as a link
Button is polymorphic: pass `as` to change the underlying element and the
accepted props follow along type-safely. Disabled non-button elements get
`aria-disabled` and leave the tab order instead of the `disabled` attribute.
```tsx
"use client";
import { Button, Icon, HStack } from "astralis-ui";
import { ExternalLink } from "lucide-react";
export function ButtonAsLink() {
return (
{/* Renders a real ; href/target come from the anchor's prop types. */}
}
>
View on GitHub
);
}
```
Buttons compose with a surrounding [Button Group](/docs/components/button-group),
which supplies shared `variant`, `colorScheme`, `size` and `disabled` defaults.
An explicit prop on a child always wins.
## Props
Button also accepts every prop of the element it renders as (`button` by default).
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | "solid" \| "subtle" \| "surface" \| "outline" \| "text" \| "link" | `"solid"` | Visual style. surface is the bordered sibling of subtle; text is the ghost style. |
| `colorScheme` | all 15 schemes | `"brand"` | Hue the variant paints with. Use gray for a neutral button. |
| `size` | "xs" \| "sm" \| "md" \| "lg" \| "xl" | `"md"` | Height, padding, font size and icon gap scale together. |
| `rounded` | "none" \| "sm" \| "md" \| "lg" \| "xl" \| "2xl" \| "full" | `"lg"` | Corner radius. |
| `fullWidth` | boolean | `false` | Stretches the button to fill its container. |
| `disabled` | boolean | `false` | Disables the button. On non-button elements this maps to aria-disabled and removes it from the tab order. |
| `loading` | boolean | `false` | Shows a spinner and disables interaction. |
| `loadingText` | ReactNode | None | Replaces the label while loading (e.g. “Saving…”); the spinner still shows. |
| `loaderPlacement` | "start" \| "end" | `"start"` | Which side the spinner renders on. |
| `loader` | ReactNode | None | Custom spinner element, replacing the built-in one. |
| `leftIcon` | ReactNode | None | Icon rendered before the label. With no label the button becomes icon-only. |
| `rightIcon` | ReactNode | None | Icon rendered after the label. |
| `as` | ElementType | `"button"` | Element or component to render. Forwarded HTML props follow the chosen element automatically. |
## Accessibility
- Renders a native `
}>Next
);
}
```
## Orientation
`orientation="vertical"` stacks the buttons. It composes with `attached` for
vertical segmented controls.
```tsx
import { Button, ButtonGroup, HStack } from "astralis-ui";
export function ButtonGroupOrientation() {
return (
ProfileSettingsSign outDayWeekMonth
);
}
```
## Spacing
When not attached, `spacing` controls the gap between buttons.
```tsx
import { Button, ButtonGroup, Text, VStack } from "astralis-ui";
const spacings = ["none", "sm", "md", "lg"] as const;
export function ButtonGroupSpacing() {
return (
{spacings.map((spacing) => (
{spacing}
OneTwoThree
))}
);
}
```
## Disabled
Disabling the group disables every button in it, handy while a form section
is pending.
```tsx
import { Button, ButtonGroup } from "astralis-ui";
export function ButtonGroupDisabled() {
return (
CutCopyPaste
);
}
```
## Props
Button Group renders a `div` and accepts all its standard attributes.
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `orientation` | "horizontal" \| "vertical" | `"horizontal"` | Lays the buttons out in a row or a column. |
| `attached` | boolean | `false` | Welds the buttons into one segmented control: inner radii collapse and adjacent borders merge. spacing is ignored. |
| `spacing` | "none" \| "sm" \| "md" \| "lg" | `"md"` | Gap between buttons when not attached. |
| `variant` | "solid" \| "subtle" \| "surface" \| "outline" \| "text" \| "link" | None | Shared visual style pushed onto every child Button. |
| `colorScheme` | all 15 schemes | None | Shared hue pushed onto every child Button. |
| `size` | "xs" \| "sm" \| "md" \| "lg" \| "xl" | None | Shared size pushed onto every child Button. |
| `disabled` | boolean | `false` | Disables every child Button at once. |
## Accessibility
- Renders with `role="group"` by default; pass a `role` (e.g. `toolbar`) and
an `aria-label` to describe the set when the context isn't obvious.
- Attached icon-only buttons still need individual `aria-label`s. See the
alignment toolbar demo.
---
# Floating Button
A button pinned above the page, anchored to a corner or edge midpoint by
default, and draggable anywhere. Built on [Button](/docs/components/button),
so every variant, size and `colorScheme` comes along.
```tsx
"use client";
import { Box, FloatingButton, Icon, Text, VStack } from "astralis-ui";
import { MessageCircle } from "lucide-react";
/*
* The `transform: translateZ(0)` on the panel is load-bearing: a transformed
* ancestor becomes the containing block for `position: fixed`, so the button
* pins to this panel instead of the real viewport. Dragging is disabled in
* these contained demos: the drag math works in viewport coordinates, which a
* containing block deliberately breaks.
*/
export function FloatingButtonDemo() {
return (
Your app
The button floats above the content, pinned to a corner.
}
>
Chat
);
}
```
## Import
```tsx
import { FloatingButton } from "astralis-ui";
```
## Usage
The default is a `solid` brand pill resting in the bottom-right corner, with
elevation and a full radius: the affordance of something sitting *above* the
page rather than in it. It renders in a `position: fixed` wrapper just under
the overlay layer, so it clears page chrome but stays beneath modals and
drawers.
The demos on this page are fenced into their panels (a transformed ancestor
becomes the containing block for `fixed`), so they rest against the panel
instead of your screen.
## Placement
Six resting anchors: the four corners plus the two horizontal edge midpoints.
`offset` controls how far it sits from the edges.
```tsx
"use client";
import { Box, FloatingButton } from "astralis-ui";
const anchors = [
{ placement: "top-left", colorScheme: "teal" },
{ placement: "center-top", colorScheme: "blue" },
{ placement: "top-right", colorScheme: "purple" },
{ placement: "bottom-left", colorScheme: "pink" },
{ placement: "center-bottom", colorScheme: "brand" },
{ placement: "bottom-right", colorScheme: "green" },
] as const;
/* One panel, six resting anchors: four corners plus the two edge midpoints.
The transform scopes `fixed` to the panel; see floating-button-demo.tsx. */
export function FloatingButtonPlacements() {
return (
{anchors.map(({ placement, colorScheme }) => (
{placement}
))}
);
}
```
## Icon-only
Omit the label and Button's icon-only behavior kicks in: a square frame that
the default `rounded="full"` turns into the classic circular FAB. Icon-only
buttons **must** carry an `aria-label`. The library warns in development
when one is missing.
```tsx
"use client";
import { Box, FloatingButton, Icon } from "astralis-ui";
import { Plus, Pencil, ArrowUp } from "lucide-react";
/* Icon-only: with no label, Button gives the FAB a square frame, and the
default `rounded="full"` turns it into the classic circle. */
export function FloatingButtonIcon() {
return (
}
/>
}
/>
}
/>
);
}
```
## Dragging
Dragging is on by default and pointer-based, so mouse, touch and pen all work
from one path. A press only becomes a drag past `dragThreshold`. Below that
it stays a click, so a slightly shaky tap still fires the action. With the
button focused, arrow keys nudge it 8px per press (1px with Shift held),
which keeps repositioning reachable without a pointer.
The launcher floating on this very site is a draggable `FloatingButton`. Try
moving it. The contained demos above disable dragging because the drag math
works in viewport coordinates, which the demo panels deliberately break.
Set `draggable={false}` when the position is part of the design.
## Controlled position
Pass `position` with `onPositionChange` to own the position yourself, for
example to restore it across visits. `onPositionCommit` fires once when a
drag ends, which is the hook for persisting; `onPositionChange` fires every
frame, which is not.
```tsx
const [position, setPosition] = useState(
() => readSavedPosition(), // null = rest at `placement`
);
Chat
;
```
A dragged position is clamped to the viewport (minus `edgePadding`) during
the drag and again on window resize, so a shrinking window can never strand
the button off-screen.
## Props
FloatingButton extends [Button](/docs/components/button), so `variant`,
`colorScheme`, `size`, `rounded`, icons and loading states all apply:
everything except `fullWidth`, which makes no sense on a floating element.
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `placement` | "bottom-right" \| "bottom-left" \| "top-right" \| "top-left" \| "center-bottom" \| "center-top" | `"bottom-right"` | Anchor the button rests at until it is dragged: a corner or an edge midpoint. |
| `offset` | "sm" \| "md" \| "lg" | `"md"` | Distance from the viewport edges at rest. |
| `draggable` | boolean | `true` | Allow repositioning by pointer drag and arrow keys. Disable for a button that must stay put. |
| `position` | { x: number; y: number } \| null | None | Controlled position in viewport pixels. Pass with onPositionChange to own the position yourself; omit to let the button track it internally. |
| `onPositionChange` | (position: { x: number; y: number }) => void | None | Fires on every drag frame, and on keyboard repositioning. |
| `onPositionCommit` | (position: { x: number; y: number }) => void | None | Fires once when a drag ends, the right hook for persisting a position. |
| `edgePadding` | number | `8` | Clearance kept between the button and the viewport edges when clamping. |
| `dragThreshold` | number | `4` | Pointer travel (px) before a press becomes a drag. Below it the gesture is still a click, so a shaky tap does not swallow the action. |
| `wrapperClassName` | string | None | Class for the fixed wrapper element; className still targets the button itself. |
## Accessibility
- A real `` via Button, so keyboard and screen-reader semantics come
for free.
- Arrow-key repositioning works whenever the button has focus; Shift steps by
1px for fine placement.
- The click that browsers fire after a drag is swallowed, so repositioning
never accidentally triggers the action.
- Icon-only usage requires an `aria-label`; a development-mode warning fires
when it's missing.
---
# Theme Toggle
A ready-made light/dark switch built on Button, with a cross-fading sun and moon.
```tsx
import { ThemeToggle } from "astralis-ui";
export function ThemeToggleDemo() {
return ;
}
```
## Import
```tsx
import { ThemeToggle } from "astralis-ui";
```
## Usage
Drop it anywhere inside an `AstralisProvider`: it reads and writes the theme
through the same context the whole library uses, and persists the choice to
`localStorage`. Every demo on this page is live: clicking one switches this
site's theme, and the icon rotates a quarter turn as sun and moon cross-fade.
The one in this site's header is this exact component.
## With a label
`showLabel` adds a text label that names the mode a click switches **to**.
Sizing follows Button's scale, and the icon scales with it.
```tsx
import { ThemeToggle, HStack } from "astralis-ui";
export function ThemeToggleLabel() {
return (
);
}
```
## Variants
Theme Toggle extends [Button](/docs/components/button), so every variant,
`colorScheme`, `size` and `rounded` value works.
```tsx
import { ThemeToggle, HStack } from "astralis-ui";
export function ThemeToggleVariants() {
return (
);
}
```
## Reading the theme yourself
For custom controls, the same context is available via the `useTheme` hook:
```tsx
import { useTheme } from "astralis-ui";
function CustomSwitch() {
const { theme, resolvedTheme, setTheme } = useTheme();
// theme: "light" | "dark" | "system" (the user's setting)
// resolvedTheme: "light" | "dark" (what's actually applied)
return (
setTheme(resolvedTheme === "dark" ? "light" : "dark")}>
Switch to {resolvedTheme === "dark" ? "light" : "dark"}
);
}
```
## Props
Theme Toggle accepts every [Button](/docs/components/button) prop except
`children` and `onClick` (it owns both). The most relevant:
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `showLabel` | boolean | `false` | Shows a text label next to the icon: “Light Mode” or “Dark Mode”, reflecting what a click switches to. |
| `variant` | "solid" \| "subtle" \| "surface" \| "outline" \| "text" \| "link" | `"outline"` | Inherited from Button. Every Button variant works. |
| `size` | "xs" \| "sm" \| "md" \| "lg" \| "xl" | `"md"` | Inherited from Button; the icon scales with it. |
## Accessibility
- Without a label it carries `aria-label="Toggle theme"` automatically; with
`showLabel` the visible text is the accessible name.
- Must be rendered inside an `AstralisProvider`: `useTheme` throws otherwise.
---
# Copy Button
A [Button](/docs/components/button) that writes `value` to the clipboard and
confirms with a transient copied state. It is the general-purpose sibling of
`CodeBlock.CopyTrigger`. Every Button prop (variant, size, colorScheme, …)
passes straight through.
```tsx
import { CopyButton, HStack } from "astralis-ui";
export function CopyButtonDemo() {
return (
Copy link
);
}
```
## Import
```tsx
import { CopyButton } from "astralis-ui";
```
## Usage
`value` is what lands on the clipboard; the label is separate, so a button
can say "Copy link" while copying the full URL. After a successful copy the
label swaps to `copiedLabel` for `timeout` milliseconds, and the button
carries a `data-copied` attribute you can style against. If the browser
refuses clipboard access, the label stays put rather than claiming a copy
that didn't happen.
```tsx
import { CopyButton } from "astralis-ui";
export function CopyButtonLabels() {
return (
Copy invite code
);
}
```
## Props
Takes every [Button](/docs/components/button) prop except `leftIcon`, which
the copied state owns. The ones that matter most:
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | string | None | The text written to the clipboard. Required. |
| `children` | ReactNode | `"Copy"` | Label at rest. |
| `copiedLabel` | ReactNode | `"Copied"` | Label while the copied state lasts. |
| `timeout` | number | `1600` | How long the copied state lasts, in milliseconds. |
| `variant` | "solid" \| "subtle" \| "surface" \| "outline" \| "text" \| "link" | `"solid"` | Button style, as on Button. |
| `size` | "xs" \| "sm" \| "md" \| "lg" \| "xl" | `"md"` | Button size, as on Button. |
| `colorScheme` | all 15 schemes | `"brand"` | Button hue, as on Button. |
## Accessibility
The copied state changes the button's label, but it isn't a live region, so a
screen reader reports it only when the button is read again. Keep
`copiedLabel` meaningful on its own ("Link copied" rather than "Done").
---
# Toolbar
A `role="toolbar"` container for grouped controls, on the APG toolbar
pattern: arrow keys move focus between the focusable children, Home/End
jump to the edges. Compose it with [Button](/docs/components/button),
[SegmentedControl](/docs/components/segmented-control),
[CopyButton](/docs/components/copy-button), or anything focusable.
```tsx
import { Toolbar, Button } from "astralis-ui";
import { Bold, Italic, Underline, Link2, List, ListOrdered } from "lucide-react";
export function ToolbarDemo() {
return (
);
}
```
## Import
```tsx
import { Toolbar } from "astralis-ui";
```
## Accessibility
- Give every toolbar a `label`: a toolbar without a name is just a row of
buttons.
- Icon-only controls need their own `aria-label` (or a
[VisuallyHidden](/docs/components/visually-hidden) text).
## Props
Extends `div` props:
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` | string | None | Accessible name, set as aria-label. A toolbar without one is just a row of buttons. |
| `orientation` | "horizontal" \| "vertical" | `"horizontal"` | Layout direction, and which arrow keys move focus. |
### Parts
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `Toolbar.Group` | div props | None | A cluster of related controls with the group role. Give it an aria-label. |
| `Toolbar.Separator` | div props | None | A vertical rule between groups. |
## Keyboard
With focus on a control in the toolbar:
| Key | Action |
| --- | --- |
| `←` / `→` | Previous / next control (horizontal; wraps at the ends) |
| `↑` / `↓` | Previous / next control (vertical; wraps at the ends) |
| `Home` / `End` | First / last control |
| `Tab` | Next control, or out of the toolbar after the last one |
Disabled controls are skipped. Every control keeps its own tab stop, so `Tab`
also walks through them one by one.
---
# Box
The foundational layout primitive: a polymorphic `div` with every style token as a typed prop.
```tsx
import { Box, Text } from "astralis-ui";
export function BoxDemo() {
return (
A humble Box
Every layout primitive in Astralis builds on this: spacing, sizing,
color, borders and radius as typed token props.
);
}
```
## Import
```tsx
import { Box } from "astralis-ui";
```
## Usage
Box turns the design system's tokens into props: spacing, sizing, color,
borders, radius, shadows and positioning, all typed: your editor
autocompletes the valid values, and anything off-scale is a type error.
Every other layout component ([Flex](/docs/components/flex),
[Stack](/docs/components/stack), [Grid](/docs/components/grid), …) extends
Box, so everything on this page applies to all of them.
```tsx
import { Box, HStack } from "astralis-ui";
export function BoxStyleProps() {
return (
);
}
```
## Responsive props
Every style prop also accepts a breakpoint map with `base`, `sm`, `md`, `lg`
and `xl` keys, resolved to precompiled classes, no runtime style computation.
```tsx
import { Box, Text } from "astralis-ui";
export function BoxResponsive() {
return (
Resize the window: padding and radius step up at md and lg.
);
}
```
```tsx
```
To respond to the space a component actually has rather than the window, mark
an ancestor with `container` and use the `@sm`–`@xl` keys, which measure that
ancestor. [Container queries](/docs/responsive#container-queries) covers the
details.
```tsx
…
```
## Polymorphism
`as` swaps the rendered element. Reach for it whenever the semantic element
matters (`section`, `aside`, `nav`, `figure` …).
```tsx
import { Box, Text } from "astralis-ui";
export function BoxAs() {
return (
/* Renders a semantic