Reading the repo
Opening the box…
Reading the repo
Opening the box…
Assembly instructions
An open source design system that's fully customizable and agent ready
Saved setup from 7 h ago
Will it work on macOS?
Also: Docs ↗
CONTRIBUTING.mdFor the full contribution process — what we accept, how to propose new components, and how API decisions are made — read the Contributing wiki.
Key pages:
This file covers local development setup.
The Node version lives in .nvmrc (currently the 24.x line). CI reads the same
file via node-version-file, so local and CI never drift apart. Don't declare
the version anywhere else.
Via nvm (recommended):
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
source ~/.zshrc
nvm install # no argument — reads .nvmrc
fnm and mise read .nvmrc too. asdf does not; its .tool-versions is
git-ignored precisely so it cannot become a competing source of truth.
Via nodejs.org: Download and install from https://nodejs.org
Astryx uses pnpm as its package manager (declared in
the packageManager field of
package.json). You can install pnpm directly:
# Via npm
npm install -g pnpm@11
# Via Homebrew (macOS)
brew install pnpm
# Via standalone installer (no npm or Node.js required)
curl -fsSL https://get.pnpm.io/install.sh | env PNPM_VERSION=11.10.0 sh -
# Via GitHub releases (single binary, no dependencies)
# https://github.com/pnpm/pnpm/releases/latest
Or use Corepack to install the exact pnpm version Astryx pins:
corepack enable
Corepack ships with Node.js 22 and 24, but current Node.js 25+ releases no
longer bundle it. If corepack is missing and you want the auto-pinning path,
install Corepack manually first:
npm install -g corepack
corepack enable
Verify installation:
node --version # v24.x.x
pnpm --version # 11.x.x
# Clone without downloading historical file contents up front
git clone --filter=blob:none https://github.com/meta/astryx.git
cd astryx
# Install dependencies
pnpm install
# Start Storybook for component development
cd apps/storybook
pnpm dev
Storybook resolves every workspace package to its src/ directory and compiles
it itself, so a fresh clone needs no build step first — pnpm install then
pnpm dev is enough.
cd apps/storybook
pnpm dev
Storybook will open at http://localhost:6006 with:
If you make changes to @astryxdesign/core: source changes update without a
rebuild or restart. Storybook and the docsite also watch
packages/core/locales/*.json, regenerate the compact runtime catalogs, and
reload imported messages after a locale edit.
The doc site (apps/docsite/) is a Next.js app that renders the component
documentation at https://astryx.atmeta.com. To run it locally:
# First time only — build the workspace packages it depends on
pnpm build
# Start the doc site (Next dev server, defaults to localhost:3000)
pnpm docsite
pnpm docsite is a thin alias for pnpm -F @astryxdesign/docsite dev,
which runs the doc site's generate step (theme CSS, registries,
playground scope) before booting Next.
Note:
pnpm docscollides with thenpm docsbuiltin, which tries to open the package's npm page in a browser. Usepnpm docsiteinstead.
astryx/
├── apps/
│ ├── storybook/ # Component playground (localhost:6006)
│ ├── docsite/ # Doc site (localhost:3000)
│ └── sandbox/ # Development testing
│
├── packages/
│ ├── core/ # Core components (Button, Input, etc.)
│ ├── cli/ # CLI tooling (astryx)
│ ├── lab/ # Experimental components (not yet stable)
│ └── themes/ # Theme presets (neutral, stone, y2k, and more)
│
└── internal/ # Internal tooling (not published)
└── test-utils/ # Shared test helpers
| Command | Description |
| ----------------- | -------------------------------------------- |
| pnpm install | Install all dependencies |
| pnpm dev | Start Storybook (alias for pnpm storybook) |
| pnpm build | Build all packages |
| pnpm test | Run all tests |
| pnpm test:watch | Run tests in watch mode |
| pnpm typecheck | Typecheck every package (after pnpm build) |
| pnpm storybook | Start Storybook at localhost:6006 |
| pnpm lint | Lint all packages |
Components use colocated tests — test files live alongside the component.
mkdir -p packages/core/src/MyComponent
packages/core/src/MyComponent/
├── MyComponent.tsx # Component implementation
├── MyComponent.test.tsx # Unit tests (colocated)
├── MyComponent.doc.mjs # Component doc (props, features, examples)
└── index.ts # Public exports
Stories are not colocated — they live in the Storybook app:
apps/storybook/stories/MyComponent.stories.tsx
// MyComponent.tsx
import type {HTMLAttributes, ReactNode, Ref} from 'react';
export interface MyComponentProps extends HTMLAttributes<HTMLDivElement> {
/** Ref forwarded to the root element */
ref?: Ref<HTMLDivElement>;
/** Description for AI-assisted development */
children: ReactNode;
}
/**
* Brief description of the component.
*
* @example
* ```
* <MyComponent>Hello</MyComponent>
* ```
*/
export function MyComponent({children, ref, ...props}: MyComponentProps) {
return (
<div ref={ref} {...props}>
{children}
</div>
);
}
MyComponent.displayName = 'MyComponent';
// MyComponent.test.tsx
import {describe, it, expect} from 'vitest';
import {render, screen} from '@testing-library/react';
import {MyComponent} from './MyComponent';
describe('MyComponent', () => {
it('renders children', () => {
render(<MyComponent>Hello</MyComponent>);
expect(screen.getByText('Hello')).toBeInTheDocument();
});
});
Stories live in the Storybook app, not next to the component —
apps/storybook/.storybook/main.ts discovers them with
'../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)', so a story anywhere under
packages/ is never picked up. Import the component through its published
entry point, and title it under Core/ (or Lab/ for @astryxdesign/lab).
// apps/storybook/stories/MyComponent.stories.tsx
import type {Meta, StoryObj} from '@storybook/react';
import {MyComponent} from '@astryxdesign/core/MyComponent';
const meta = {
title: 'Core/MyComponent',
component: MyComponent,
tags: ['autodocs'],
} satisfies Meta<typeof MyComponent>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
children: 'Hello World',
},
};
// packages/core/src/index.ts
export * from './MyComponent';
Note: Do not manually edit the
"exports"field inpackages/core/package.json. It is auto-generated from thesrc/directory byscripts/sync-exports.jsand committed automatically when changes land onmain. If you need to verify your component will be included, runpnpm sync:exports:check.
Components read user-facing strings with useTranslator() rather than
hardcoding them:
import {useTranslator} from '@astryxdesign/core/i18n';
function SaveButton() {
const t = useTranslator();
return <button>{t('@astryx.actions.save')}</button>;
}
Astryx's own strings live in packages/core/locales/en.json. The
@astryx/no-hardcoded-i18n-string ESLint rule rejects a hardcoded one. For a
prop whose default is a translated string, alias the prop and resolve it in the
body: destructure label: labelFromProps, then
const label = labelFromProps ?? t('@astryx.<component>.<key>').
A component that responds to text direction resolves it from the DOM, not from a render-time JavaScript read. Reach for the lightest tool that works, in this order:
CSS logical properties. Use insetInlineStart, paddingInlineEnd,
marginInline, and friends instead of physical left/right. Most
mirroring needs nothing more: the browser flips it from the ambient dir.
The @astryx/no-physical-properties ESLint rule enforces this.
Directional icons: mirror with CSS, not a name swap. Render one fixed
glyph and wrap it in the shared rtlStyles.mirror (a scaleX(-1) that only
applies under [dir="rtl"]). It flips from the ancestor dir through the
cascade, so it works on the server with no hydration flash. Do not pick
chevronLeft or chevronRight in JavaScript. Pagination, Calendar, and
Carousel handle their chevrons this way.
import * as stylex from '@stylexjs/stylex';
import {rtlStyles} from '@astryxdesign/core';
function NextButton() {
// One glyph; CSS flips it under RTL. No direction read.
return (
<span {...stylex.props(rtlStyles.mirror)}>
<Icon icon="chevronRight" />
</span>
);
}
Behavior: read the DOM lazily, on the event. For what CSS can't express
(arrow-key mapping, drag and scroll math), read direction at interaction
time with isRtlElement(el) (a getComputedStyle().direction check), never
during render. The focus hooks (useListFocus, useGridFocus,
useTreeFocus) already detect direction from their container, so arrow keys
flip for free; don't pass them a direction flag.
useDirection() context, as a last resort. Use it only when you need
the direction value during render and nothing above fits. It is SSR-safe
and returns 'ltr' outside a provider (like useTranslator's silent
fallback), but it is the one path that can mismatch on hydration when the
provider's direction disagrees with <html dir>. No Astryx component reads
direction from context at render time.
Add a semantic icon name only for a glyph the whole system shares:
IconName type in
packages/core/src/Icon/globalIconRegistry.tsx.packages/core/src/Icon/defaultIcons.tsx.packages/cli/assets/docs/icons.doc.mjs.A glyph that belongs to one component takes a namespaced key instead
(numberInput:stepperDown), so the shared IconName list does not grow. Ship
its fallback in defaultIcons under the same key so the glyph still renders
with no theme.
Every new component — and any change to an interactive one — must clear the
Accessibility Checklist
(wiki) before review. The checklist lives on the wiki so accessibility
experts can refine it without a code PR; reviewers block on it (it is A1–A16
on the Component Audit Rubric),
and it is a hard requirement for a lab → core promotion (see
packages/lab/README.md).
Two repo-side rules worth restating here:
VisuallyHidden, useAnnounce,
useFocusTrap, and the focus hooks (useListFocus, useGridFocus,
useTreeFocus) — rather than hand-rolling equivalents. They implement the
WAI-ARIA APG patterns and are tested once; a bespoke reimplementation of
one is a review reject.pr-a11y job in ci.yml runs an axe audit after merges to main and
before each release (and on a pull request labelled ci:run-a11y), at
full scope across the component surface, and the useAnnounce lint rule
rejects hand-wired aria-live regions. axe only
catches static, DOM-level issues — keyboard behavior, focus management, and
announcement timing are exactly what the checklist and the component's unit
tests cover.astryx CLIThe CLI (packages/cli/) is layered so behavior, presentation, and contracts stay separable:
clients/cli/ — the Commander program and per-command handlers. A handler is a thin wrapper: parse flags → call the matching api/ function → render (JSON via jsonOut, or text via the formatter kit in clients/cli/formatters/).api/ — the programmatic API (@astryxdesign/cli/api). Each command maps to api/<name>/, whose functions return a typed { type, data } envelope. This is the behavior source of truth, so astryx --json and the imported function return identical data.authoring/ — the pure data contracts (@astryxdesign/cli/authoring): the TypeScript types you author objects against (config, integration, codemod, and the doc-types) plus the sealed zod parsers the CLI runs at the load boundary.foundation/ — the bottom layer: cross-cutting infra that everything above builds on — the { type, data } JSON contract, the stable ERROR_CODES, discovery (components, templates), integration contribution validators, and path-safety. It never imports api/ or clients/; if foundation needs something, that something belongs in foundation.Every CLI surface has a colocated, typed .doc.mjs next to what it describes, annotated with a @type from @astryxdesign/cli/authoring:
| Surface | Doc-type | Lives next to |
| --------------------------------------------------------------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------- |
| An API function (a hook or an api/ export) | FunctionDoc | api/<name>/<fn>.doc.mjs |
| A CLI command | CommandDoc (references its FunctionDoc via fn) | clients/cli/commands/<name>.doc.mjs |
| An authored object (config, integration, codemod, the doc-types, the response envelope) | SchemaDoc | beside the schema (authoring/**, foundation/response/) |
| A closed vocabulary (error codes, response types) | EnumDoc | foundation/response/ |
| A long-form topic (tokens, principles, theming, …) | ReferenceDoc | assets/docs/<topic>.doc.mjs |
These are not free-form. parseDoc validates each at load, and a drift harness (packages/cli/test/drift/) enforces that they mirror their source of truth: every CommandDoc's fn/args/options match the live CLI, and the EnumDocs equal ERROR_CODES / the manifest's response-type set exactly. A doc that drifts fails CI.
Most of the conventions above are mechanical, so they're checked rather than reviewed:
| Rule | Enforced by |
| ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
| the layer directions hold: authoring/ imports no other layer, foundation/ never imports api/ or clients/, api/ never imports clients/ | ESLint (no-restricted-imports) |
| zod stays sealed behind the authoring/ parsers | ESLint (no-restricted-imports) |
| commands register via defineCommand, never straight onto Commander | ESLint (no-restricted-syntax) |
| a command handler must not access the environment: no filesystem, network, subprocess, Project, or discovery imports (INV22) | pnpm check:cli-structure |
| an API module (other than an adapter or helper imported only by adapters) must not access the environment (INV21) | pnpm check:cli-structure |
| a command handler must not build text with .padEnd(), .padStart(), .repeat(), or new Block() (INV23, FR3) | ESLint (no-restricted-syntax) + pnpm check:cli-structure |
| every executable CommandDoc fn must name a function exported from api/index.mjs (FR1) | pnpm check:cli-structure |
| every function exported from api/index.mjs must have a FunctionDoc whose name matches (FR2) | pnpm check:cli-structure |
| each doc-type ships type.ts + parse.mjs + <kind>.doc.mjs, re-exports its parser, and appears in parseDoc's @returns | pnpm check:cli-structure |
| each api/<name>/ ships its typedefs, a FunctionDoc, and a test | pnpm check:cli-structure |
| every CommandDoc/EnumDoc matches the live CLI | the drift harness |
You never hand-write the .d.mts declarations. `packages/cli/scripts/sync-api
Then run bash setup.sh. It stops at the first error and is safe to run again. Servers and tests aren't started; it lists them at the end. Read it first.
Needs Git: xcode-select --install
git clone https://github.com/meta/astryx.git cd astryx
Node.js 24.22.1
# needs nvm: brew install nvm (or https://github.com/nvm-sh/nvm) nvm install nvm use
corepack enable pnpm install
pnpm dev
pnpm test
Astryx requires React 19 or later (react and react-dom are peer dependencies of @astryxdesign/core).
Install Astryx, a theme, and its peer dependencies:
# npm
npm install @astryxdesign/core @astryxdesign/theme-neutral @stylexjs/stylex
npm install -D @astryxdesign/cli
# pnpm
pnpm add @astryxdesign/core @astryxdesign/theme-neutral @stylexjs/stylex
pnpm add -D @astryxdesign/cli
# yarn
yarn add @astryxdesign/core @astryxdesign/theme-neutral @stylexjs/stylex
yarn add -D @astryxdesign/cli
You can also install the CLI once for your computer, so astryx works in any folder and every coding agent knows Astryx; in a project, it still runs that project's own version:
npm i -g @astryxdesign/cli
Project install, global install, or both? astryx docs global/project-global-or-both compares them (both is recommended), and astryx docs global explains how global mode works.
The simplest setup is a few CSS imports plus a theme provider — no build plugin, no PostCSS or Babel config. See the @astryxdesign/core README for the full guide (Next.js, Tailwind, Vite, and CDN).
For reliable CLI access, add a script to your package.json:
"scripts": {
"astryx": "node node_modules/@astryxdesign/cli/clients/cli/bin/astryx.mjs"
}
Then use it as npm run astryx -- component --list. This avoids path errors when AI assistants or new developers invoke the CLI directly.
No reports for macOS yet.
GitManual AI Agent reads the 10 key files and writes a step-by-step guide for macOS. Once written, it's saved for everyone.