# Introduction Open Components is the UXFront component standard: guidelines for building UI components with a perfect user experience, developer experience and agentic experience, whether humans or AI agents write them. Learn the guidelines once, then hold every component to the same high standards, whether you or your agents write it. ## The three layers The standard has three layers, one for each audience a component serves. A component meets the standard when it meets all three. ### User experience Components behave the way people expect them to, with any input, on any device and for every ability. - **Accessible**: keyboard, pointer, touch and screen readers, following [WAI-ARIA](https://www.w3.org/WAI/ARIA/apg/){rel=""nofollow""}. - **Predictable**: the same interaction works the same way in every component. - **Every state**: hover, focus, active, disabled, loading and error, all designed. - **Adaptive**: respects motion, contrast, colour scheme and text size preferences. ### Developer experience One clear API, learned once and used everywhere. Knowing one component means knowing them all. - **Consistent**: sizes, colours, states and events share their names everywhere. - **Type-safe**: every prop, event and slot is typed and documented. - **Composable**: small parts build larger ones, with no hidden coupling. - **Controllable**: controlled or uncontrolled, with every state within reach. ### Agentic experience Components that AI agents can read, reason about and build with, so their output meets the same bar as yours. - **Semantic**: roles, names and states agents can read straight from the markup. - **Described**: anatomy, props and states, published as machine-readable specs. - **Deterministic**: the same input always renders the same structure. - **Verifiable**: rules agents can check their work against before shipping. ## Foundations Some rules hold for every component rather than for one. [Design Tokens](https://opencomponents.dev/docs/foundations/design-tokens) covers how to name the CSS variables behind them, a convention we call token paths, and the theme tokens they all share. ## Components Each component page holds one component to the three layers, and also covers how it looks, which we call its UI. Every page follows the same structure, so you and your agents always know where to look: - **At a glance**: a single table with its element, role, keyboard support and options. - **UI**: its anatomy, variants, colours, sizes, states and tokens. - **UX**, **DX** and **AX**: the three layers, one trait at a time. - **Checklist**: every rule, with a stable ID like `button/keep-focus`, a level, a scope that says whether the component or the code using it meets the rule, a basis that says where it comes from, like a WCAG success criterion or our own call, and a way to check it. - **Reference implementation**: a component that does its part of every rule, along with its tests. The code is in Vue for now. The [Button](https://opencomponents.dev/docs/components/button) is a great place to start, and the [Roadmap](https://opencomponents.dev/docs/getting-started/roadmap) lists the components we plan to ship next. ## For agents If your agent supports MCP, connect it to our [MCP server](https://opencomponents.dev/docs/getting-started/mcp-server) at `https://mcp.opencomponents.dev/mcp`. It gives your agent the contracts, every rule and the docs as tools, so it reads a component's contract first, then only the sections it needs rather than the whole page. Our [agent plugin](https://opencomponents.dev/docs/getting-started/agent-plugin) connects the server for you, along with skills that build and review components the way the standard asks, so your agent follows it without being told how. It works in VS Code, GitHub Copilot, Cursor, Codex, Claude Code and other clients that support [Agent Plugins](https://agent-plugins.org){rel=""nofollow""}. Every page of these docs is also published as markdown: prefix its path with `/raw` and add `.md`, as in [`/raw/docs/getting-started/introduction.md`](https://opencomponents.dev/raw/docs/getting-started/introduction.md){external=""}. [`/llms.txt`](https://opencomponents.dev/llms.txt){external=""} lists every page, and [`/llms-full.txt`](https://opencomponents.dev/llms-full.txt){external=""} holds them all in one file. Pages that describe a contract, like the Button's, also publish it on its own, with every rule from their checklist: swap `.md` for `.yaml`, as in [`/raw/docs/components/button.yaml`](https://opencomponents.dev/raw/docs/components/button.yaml){external=""}. It's a fraction of the page's length, for when an agent only needs the rules, and not the reasoning behind them. Every contract follows the same [JSON Schema](https://opencomponents.dev/schemas/contract.json){external=""}, so you and your agents can validate one, including a contract you write for a component of your own. Each contract names the schema on its first line, so editors that use the YAML language server, like VS Code with its YAML extension, check it as you type. # Roadmap Open Components grows one component at a time. We'd love to cover every component you reach for, but a page only helps you if you can trust it, so we write each one in full before we move on to the next. This page shows you where we are, and what's coming next. Every component gets a page with the same structure as the [Button](https://opencomponents.dev/docs/components/button)'s, so you always know where to look: - **User Interface** (UI) - how it looks, from its anatomy to its tokens - **User Experience** (UX) - how it behaves for every person and every input - **Developer Experience** (DX) - how developers work with it - **Agentic Experience** (AX) - how agents can read and verify it On top of that, each page comes with a checklist of every rule, a contract your agents can check their work against, and a reference implementation that does its part of every rule, along with the tests that prove it. That's a lot to get right, so a page only ships once all of it is ready. We'd much rather give you one page you can rely on than several you can't. ## Components Here's every component we plan to cover. A tick means its page has shipped, so you can read it today. We've grouped them by what they build on, so each group can reuse what the ones before it have already settled, like the Button's sizes and colours, or how the Dialog handles focus. The order may still change as we go, and you can help shape it. ### Actions and forms Let's start with the components people use to act and to fill in forms. The Button sets the sizes, colours and states that every other component shares, and the form controls build on them with the labels, hints, validation and controlled values that every form needs. | Component | What it's for | | ------------------------------------------------------------ | ----------------------------------------------------------------------------- | | [Button](https://opencomponents.dev/docs/components/button) | Acting on what's in front of you, from saving a form to deleting a file | | Field | Labelling a form control, with a hint and an error message when it needs them | | [Input](https://opencomponents.dev/docs/components/input) | Typing a single line of text, like a name, an email address or a search | | Textarea | Writing longer text, like a comment or a message | | Checkbox | Turning options on and off, on their own or in a group | | Radio Group | Picking one option out of a few, when they all fit on the screen | | Switch | Flipping a setting on or off, with an effect you see right away | | Select | Picking one option out of many, from a list | ### Overlays Next come the components that sit on top of the page. They all need to take focus and give it back, close with `Escape` or a click outside, and stack in the right order, so we'll settle those rules once and share them. | Component | What it's for | | -------------- | ----------------------------------------------------------------------------- | | Dialog | Asking for someone's full attention, and holding focus until they're done | | Popover | Showing more next to what opened it, without blocking the rest of the page | | Tooltip | Giving a short hint on hover and focus, like an icon-only button's label | | Dropdown Menu | Tucking a list of actions behind a single button | | Toast | Confirming something briefly without getting in the way, like "Changes saved" | ### Navigation and disclosure These help people find their way around a site, or show and hide what's on a page. Most of them build on the Link and the Button, and add the keyboard support people expect, like moving between tabs with the arrow keys. | Component | What it's for | | ----------- | ------------------------------------------------------------------ | | Link | Taking people somewhere else, from inside a sentence or on its own | | Tabs | Switching between panels of related content, one at a time | | Accordion | Expanding and collapsing sections, like the questions in an FAQ | | Breadcrumb | Showing where a page sits in the site, and the way back up | | Pagination | Moving through a long list of results, a page at a time | ### Feedback and display These tell people what's going on, and most of them aren't interactive at all. That's why their rules are mostly about meaning and contrast, and about making sure screen readers hear about what changes. | Component | What it's for | | -------------------------------------------------------------- | --------------------------------------------------------------------------------- | | [Spinner](https://opencomponents.dev/docs/components/spinner) | Showing that something is busy, for a wait of unknown length | | Alert | Telling people something important, from a success to an error, right on the page | | Badge | Marking a status or a count, like "New" or "3" | | Avatar | Showing a person or a team, with a photo, their initials or an icon | | Progress | Showing how far along a task is, even when you can't tell how long it'll take | | Skeleton | Holding the layout while content loads, so nothing jumps around when it arrives | | Card | Grouping related content, like a project or a product, in one container | ### Later Finally, the composite widgets. They have the most keyboard behaviour of all, and they build on almost everything above, which is why they come last. A combobox, for example, is an input, a popover and a list working together. | Component | What it's for | | ---------------- | -------------------------------------------------------------------- | | Combobox | Typing to filter a list of suggestions, like when you pick a country | | Slider | Picking a value or a range by dragging, or with the arrow keys | | Date Picker | Typing a date or a range, or picking it from a calendar | | Table | Comparing rows of data, with sorting and selection | | Tree View | Browsing nested items, like the folders in a file explorer | | Command Palette | Finding any action from the keyboard, just by typing its name | ## Before a page ships You don't have to wait for a page to hold a component to the standard. The three layers in the [Introduction](https://opencomponents.dev/docs/getting-started/introduction) apply to every component, [Design Tokens](https://opencomponents.dev/docs/foundations/design-tokens) covers how to name its variables, and the [Button](https://opencomponents.dev/docs/components/button) shows how the layers turn into rules you can check. Your agents can do the same. Point them at [`/llms-full.txt`](https://opencomponents.dev/llms-full.txt){external=""}, which holds the whole standard in one file, and ask them to follow the Button's structure. For a component that doesn't have its page yet, you could use: ```text Build a Tabs component for our design system that meets the Open Components standard: https://opencomponents.dev/llms-full.txt There's no Tabs page yet, so follow the Button page's structure. Work through the UI, UX, DX and AX of tabs, following the WAI-ARIA Tabs pattern, then write a checklist with a stable ID for every rule, like tabs/arrow-keys, and a test for each one. ``` ## Suggest a component Is a component you need missing, or further down the list than you'd like? [Open an issue](https://github.com/uxfront-com/open-components/issues/new/choose){rel=""nofollow""} and tell us what you're building with it. Knowing what you need helps us decide what comes next. # MCP Server Our MCP server gives your agents the whole standard as tools: the contracts, every rule, the docs and the reference implementations. Connect it once, then ask your agent to build or review a component. It reads only what the task needs, then checks its work rule by rule, citing each rule by its ID. It works with Claude, ChatGPT, Codex, Cursor, VS Code, GitHub Copilot, Gemini CLI and any other client that supports remote servers over the [Model Context Protocol](https://modelcontextprotocol.io){rel=""nofollow""}. It's free, with nothing to install and no account or API key to set up. This page covers: - **Quick start** - connect it and try it in a minute - **Connect your client** - the setup for each client, step by step - **What you can ask** - prompts for building, reviewing and learning the standard - **How it works** - what your agent reads, and in what order - **Tools, prompts and resources** - everything the server offers - **Privacy and security** - what it sees, and what it can't do - **Troubleshooting** - what to check when it doesn't connect ## At a glance | Aspect | MCP server | | -------------- | ------------------------------------------------------------------------------------------------------------- | | URL | `https://mcp.opencomponents.dev/mcp` | | Name | `open-components`, the name to add it under | | Transport | Streamable HTTP | | Authentication | None, so where a client asks, choose none | | Access | Read-only. It serves the standard and changes nothing | | Cost | Free | | Tools | `list-components`, `get-contract`, `list-rules`, `get-page`, `search-docs` and `get-reference-implementation` | | Prompts | `build-component`, `review-component`, `review-usage` and `adopt-token-paths` | | Content | The same files as this site, redeployed whenever they change | ## Quick start ::steps{level="3"} ### Add the server In a coding agent, it takes one command: :::code-group ```bash [Claude Code] claude mcp add --transport http open-components https://mcp.opencomponents.dev/mcp ``` ```bash [Codex] codex mcp add open-components --url https://mcp.opencomponents.dev/mcp ``` ```bash [Gemini CLI] gemini mcp add --scope user --transport http open-components https://mcp.opencomponents.dev/mcp ``` ```bash [Copilot CLI] copilot mcp add --transport http open-components https://mcp.opencomponents.dev/mcp ``` ::: In an editor, open the [Cursor install link](https://mcp.opencomponents.dev/mcp/deeplink){rel=""nofollow""} or the [VS Code install link](https://mcp.opencomponents.dev/mcp/deeplink?ide=vscode){rel=""nofollow""}. On every page of these docs, the menu next to **Copy page** can also copy the server's URL for you, with **Copy MCP Server URL**, or install it in Cursor, with **Add MCP Server**. For any other client, see [Connect your client](https://opencomponents.dev/#connect-your-client). To get skills that build and review components along with the server, install our [agent plugin](https://opencomponents.dev/docs/getting-started/agent-plugin) instead. ### Check that it's connected Run `/mcp` in Claude Code, Codex or Gemini CLI, or `/mcp show open-components` in Copilot CLI, to check that `open-components` is connected. In an editor, look for it in the MCP settings, along with its six tools. ### Ask your agent Ask for what you need in your own words. Mention the server by name the first time, so your agent knows to reach for it: ```text Build a Button component for our React design system that meets the Open Components standard, using the open-components MCP server. Check your work against every rule with a component or both scope, and cite the rule ID for any rule you can't meet. ``` [What you can ask](https://opencomponents.dev/#what-you-can-ask) has more prompts to start from. :: ## Connect your client Every client needs the same three things: the URL, `https://mcp.opencomponents.dev/mcp`, the name, `open-components`, and no authentication. Most take a single command or a few lines of configuration. There's nothing to sign in to, so where a client asks about authentication, choose none. ### Coding agents ::tabs :::tabs-item{label="Claude Code"} ```bash claude mcp add --transport http open-components https://mcp.opencomponents.dev/mcp ``` That adds it for you, in the project you're in. Add `--scope user` to use it in every project, or `--scope project` to share it with your team through the project's `.mcp.json`, which asks each of them to approve it once. Run `/mcp` in Claude Code to check that it's connected. ::: :::tabs-item{label="Codex"} ```bash codex mcp add open-components --url https://mcp.opencomponents.dev/mcp ``` That adds it to `~/.codex/config.toml`, which the Codex CLI, the Codex extension for your editor and Codex in the ChatGPT desktop app all read. To share it with your team, add it to `.codex/config.toml` in your project instead, which Codex reads once you trust the project: ```toml [.codex/config.toml] [mcp_servers.open-components] url = "https://mcp.opencomponents.dev/mcp" ``` Run `/mcp` in Codex to check that it's connected. ::: :::tabs-item{label="Gemini CLI"} ```bash gemini mcp add --scope user --transport http open-components https://mcp.opencomponents.dev/mcp ``` That adds it to `~/.gemini/settings.json`, which Gemini Code Assist's agent mode in VS Code (Standard and Enterprise) reads too, after **Developer: Reload Window**. Leave out `--scope user` to add it to `.gemini/settings.json` in the folder you're in instead, and start `gemini` from that folder, though not from your home folder, where it reports success but saves nothing. Run `/mcp` in Gemini CLI to check that it's connected. ::: :::tabs-item{label="Copilot CLI"} ```bash copilot mcp add --transport http open-components https://mcp.opencomponents.dev/mcp ``` That adds it to `~/.copilot/mcp-config.json`. Run `/mcp show open-components` in a Copilot CLI session to check that it's connected. ::::note On Copilot Business and Enterprise, an organization owner turns on the **MCP servers in Copilot** policy first. :::: ::: :::tabs-item{label="Grok Build"} In Grok Build, xAI's coding agent: ```bash grok mcp add --transport http open-components https://mcp.opencomponents.dev/mcp ``` That adds it to `~/.grok/config.toml`. Run `grok mcp doctor open-components` to check that it's connected. ::: :::tabs-item{label="Hermes Agent"} ```bash hermes mcp add open-components --url https://mcp.opencomponents.dev/mcp ``` Answer `n` when it asks whether the server needs authentication, then let it enable every tool. That adds it to `~/.hermes/config.yaml` (`%LOCALAPPDATA%\hermes\config.yaml` on Windows), which you can also edit yourself: ```yaml [~/.hermes/config.yaml] mcp_servers: open-components: url: "https://mcp.opencomponents.dev/mcp" ``` Run `hermes mcp test open-components` to check that it's connected. In the Hermes desktop app, add it under **Capabilities > Connectors > Add your own**: set **Type** to **Streamable HTTP**, leave **Auth** on **None** and click **Save**. ::: :: ### Editors ::tabs :::tabs-item{label="Cursor"} Open the [Cursor install link](https://mcp.opencomponents.dev/mcp/deeplink){rel=""nofollow""}, or add the server to `.cursor/mcp.json` in your project, or to `~/.cursor/mcp.json` to use it in every project: ```json [.cursor/mcp.json] { "mcpServers": { "open-components": { "url": "https://mcp.opencomponents.dev/mcp" } } } ``` ::: :::tabs-item{label="VS Code"} Open the [VS Code install link](https://mcp.opencomponents.dev/mcp/deeplink?ide=vscode){rel=""nofollow""}, or add the server to a `.mcp.json` at your project's root, which Claude Code reads too: ```json [.mcp.json] { "mcpServers": { "open-components": { "type": "http", "url": "https://mcp.opencomponents.dev/mcp" } } } ``` In `.vscode/mcp.json`, the same entry goes under `servers` rather than `mcpServers`. Copilot Chat can then use its tools in agent mode. ::::note On Copilot Business and Enterprise, an organization owner turns on the **MCP servers in Copilot** policy first. :::: ::: :::tabs-item{label="Visual Studio"} Add the server to `.mcp.json` in your solution, or to `%USERPROFILE%\.mcp.json` to use it in every solution: ```json [.mcp.json] { "servers": { "open-components": { "type": "http", "url": "https://mcp.opencomponents.dev/mcp" } } } ``` Then turn on its tools from **Tools** in Copilot Chat's agent mode. ::::note On Copilot Business and Enterprise, an organization owner turns on the **MCP servers in Copilot** policy first. :::: ::: :::tabs-item{label="JetBrains"} In AI Assistant 2025.3 or later, go to **Settings > Tools > AI Assistant > Model Context Protocol (MCP)**, click **Add**, choose **HTTP** and paste: ```json { "mcpServers": { "open-components": { "url": "https://mcp.opencomponents.dev/mcp" } } } ``` Click **OK**, then **Apply**. To let Junie and the other agents in your IDE use it too, turn on **Pass custom MCP servers** under **Settings > Tools > AI Assistant > Agents**. For Junie CLI, add the same entry to `~/.junie/mcp/mcp.json`, or to `.junie/mcp/mcp.json` in your project. ::: :::tabs-item{label="Zed"} Add it under **Settings > AI > MCP Servers > Add Server > Add Remote Server** (in older releases, in the Agent Panel's settings), or to `context_servers` in your `settings.json`: ```json [settings.json] { "context_servers": { "open-components": { "url": "https://mcp.opencomponents.dev/mcp" } } } ``` The dot next to it turns green once it's connected. ::: :::tabs-item{label="Antigravity"} ```bash agy mcp add open-components https://mcp.opencomponents.dev/mcp ``` That adds it to `~/.gemini/config/mcp_config.json`, which the Antigravity CLI, the IDE and the desktop app share. In the IDE, open it from the agent panel's **…** menu, under **MCP Servers > Manage MCP Servers > View raw config**. Give a remote server a `serverUrl` there, the key Antigravity's docs ask for: ```json [~/.gemini/config/mcp_config.json] { "mcpServers": { "open-components": { "serverUrl": "https://mcp.opencomponents.dev/mcp" } } } ``` ::: :::tabs-item{label="Windsurf and Devin"} In Windsurf, add the server to `~/.codeium/windsurf/mcp_config.json`: ```json [~/.codeium/windsurf/mcp_config.json] { "mcpServers": { "open-components": { "serverUrl": "https://mcp.opencomponents.dev/mcp" } } } ``` With the Devin CLI: ```bash devin mcp add -s user open-components https://mcp.opencomponents.dev/mcp ``` ::: :: ### Chat apps ::tabs :::tabs-item{label="Claude"} In Claude and Claude Desktop, add the server as a custom connector: 1. Go to **Customize > Connectors**, click **+ Add**, then **Add custom connector**. 2. Name it `open-components` and enter the URL. 3. Under **Authentication**, choose **No sign in**. You can then turn it on for a conversation from the **+** button, under **Connectors**. ::::note On Team and Enterprise plans, an owner adds it for everyone first, under **Organization settings > Connectors** . :::: ::: :::tabs-item{label="ChatGPT"} You can add it on the web, with a Plus, Pro, Business, Enterprise or Edu plan: 1. Turn on **Developer mode** in ChatGPT's settings. 2. Create an app named `open-components` with the URL, set **Authentication** to **No Authentication** and confirm that you trust the server. 3. In a new chat, pick it from the **+** menu, under **Developer mode**. The menus can differ a little from plan to plan. ::::note On Enterprise and Edu workspaces, an admin allows **Developer mode** first. On Business, only admins and owners can use it, so they add the app for everyone. :::: ::: :::tabs-item{label="Grok"} On [grok.com](https://grok.com/connectors){rel=""nofollow""}: 1. Open **Connectors**, choose **New Connector**, then **Custom**. 2. Name it `open-components`, enter the URL as its **Server URL** and click **Add Connector**. ::::note On Grok Business and Enterprise, an admin adds it for everyone first, in the xAI console under **Grok Business > Connectors** . :::: ::: :: ### Cloud agents and APIs ::tabs :::tabs-item{label="Copilot cloud agent"} For Copilot's cloud agent on github.com, a repository admin pastes this under the repository's **Settings > Copilot > MCP servers**, then clicks **Save MCP configuration**: ```json { "mcpServers": { "open-components": { "type": "http", "url": "https://mcp.opencomponents.dev/mcp", "tools": ["*"] } } } ``` ::::note On Copilot Business and Enterprise, an organization owner turns on the **MCP servers in Copilot** and **Copilot cloud agent** policies first. :::: ::: :::tabs-item{label="xAI API"} Pass the server to the Responses API as a tool, and xAI connects to it for you: ```json "tools": [ { "type": "mcp", "server_url": "https://mcp.opencomponents.dev/mcp", "server_label": "open-components" } ] ``` ::: :: ### Other clients - **Amp**: ```bash amp mcp add open-components https://mcp.opencomponents.dev/mcp ``` - **Cline**: open **Customize** (the wrench), then **MCP > Add Remote Server**, and keep **Streamable HTTP** as its transport. - **Continue**: add the server to the `mcpServers` list in `~/.continue/config.yaml`. Continue uses it in Agent and Plan modes. ```yaml mcpServers: - name: open-components type: streamable-http url: https://mcp.opencomponents.dev/mcp ``` - **Crush**: from Crush 0.88, add this line to `~/.config/crush/crushrc`: ```bash mcp add open-components --type http --url https://mcp.opencomponents.dev/mcp ``` - **Factory Droid**: ```bash droid mcp add open-components https://mcp.opencomponents.dev/mcp --type http ``` - **goose**: in the desktop app, go to **Extensions > Add custom extension**, choose **Streamable HTTP** as its type and enter the URL as its endpoint. In the CLI, run `goose configure` and choose **Add Extension > Remote Extension (Streamable HTTP)**. - **LM Studio**: add the server under `mcpServers` in `~/.lmstudio/mcp.json`, which **Program > Install > Edit mcp.json** opens: ```json { "mcpServers": { "open-components": { "url": "https://mcp.opencomponents.dev/mcp" } } } ``` - **OpenCode**: ```bash opencode mcp add open-components --url https://mcp.opencomponents.dev/mcp ``` :brIn releases from before June 2026, run `opencode mcp add` and answer its questions instead. - **Qwen Code**: ```bash qwen mcp add --transport http open-components https://mcp.opencomponents.dev/mcp ``` - **Warp**: under **Settings > Agents > MCP servers**, click **+ Add** and paste: ```json { "open-components": { "url": "https://mcp.opencomponents.dev/mcp" } } ``` Any other client that supports remote MCP servers over streamable HTTP only needs the URL. If yours only runs local servers, it can still reach this one through [mcp-remote](https://www.npmjs.com/package/mcp-remote){rel=""nofollow""}, which you add as a local server with this command: ```bash npx -y mcp-remote https://mcp.opencomponents.dev/mcp ``` ## What you can ask Once the server is connected, ask in your own words. Mention it by name the first time, and your agent takes it from there: the server tells it which tools to use, and in what order. Here's what it can help with, and a prompt to start from for each. ### Build a component ```text Build a Button component for our React design system that meets the Open Components standard, using the open-components MCP server. Check your work against every rule with a component or both scope, and cite the rule ID for any rule you can't meet. ``` Your agent reads the Button's contract with `get-contract`, ports the reference implementation and its tests from `get-reference-implementation`, and reads how the API looks in React with `get-page`. Then it checks its work against the contract's rules with a component or both scope. For a component on the [Roadmap](https://opencomponents.dev/docs/getting-started/roadmap), which has no contract yet, it holds it to the three layers and Design Tokens instead, following the Button's structure. The `build-component` prompt asks for the same. ### Review a component ```text Review our Button, in src/components/Button.tsx, against the Open Components standard, using the open-components MCP server. Report every rule with a component or both scope as pass or fail, with its rule ID and the evidence from our code. Then fix the failures. ``` Your agent lists the rules to report on with `list-rules`, then checks your code against each one. For a rule it's unsure of, it finds the section that explains it with `search-docs`, and reads it with `get-page`. The `review-component` prompt asks for the same. ### Review a screen ```text Review how our checkout page uses buttons against the Open Components standard, using the open-components MCP server. Report every rule with a usage or both scope as pass or fail, with its rule ID and the evidence from our code. ``` Usage rules are the ones the code using a component meets, like `button/one-primary`, which keeps one solid primary button per view. Your agent lists them with `list-rules`, with a usage or both scope. The `review-usage` prompt asks for the same. ### Move to token paths ```text Rename our CSS variables to follow the Open Components token paths, using the open-components MCP server. List our token groups first, then rename every variable along with every var() that reads it, keeping the values as they are. Then add the Stylelint rule from the standard, and fix anything it reports. ``` Your agent reads the [Design Tokens](https://opencomponents.dev/docs/foundations/design-tokens) contract with `get-contract`, and the Stylelint rule from the page's Lint section with `get-page`. The `adopt-token-paths` prompt asks for the same. ### Learn the standard ```text Using the open-components MCP server, how should a button behave while it's loading? Link to the sections you used. ``` Your agent finds the sections that cover it with `search-docs`, then reads them with `get-page`, with the examples in your framework if you name one. To see what the standard covers so far, and what's planned, ask which components it has, and your agent lists them with `list-components`. ## How it works You could point your agent at a page instead, but component pages are long. The Button's is about 35,000 tokens, mostly reasoning and examples in seven frameworks, which is far more than any one task needs. Its contract holds every requirement in about 3,000. When your agent connects, the server tells it how to use it, so you don't have to. Your agent: 1. **Finds what the task is about**, with `list-components`, or `search-docs` for a topic. 2. **Reads the contract first**, with `get-contract`: the component's API, its DOM contract, its tokens and every rule. 3. **Reads only the sections it needs**, with `get-page`, for the reasoning or an example behind a rule, in your framework. 4. **Starts from the reference implementation**, with `get-reference-implementation`, when it builds a component. 5. **Checks its work rule by rule**, with `list-rules`, citing every rule by its ID, like `button/keep-focus`, exactly as the server writes it, so you can look each one up. ### Rules and scopes Every rule has a level. A component meets the standard when it meets every must rule, and each should rule is expected unless there's a good reason not to follow it. A component's rules also have a scope, which says whose code to check them against: | Scope | Met by | Check it when you | | ----------- | ------------------------------------------------- | --------------------------- | | `component` | The component itself | Build or review a component | | `usage` | The code that uses it | Review a screen | | `both` | The two together, like naming an icon-only button | Do either | ### Always in step with the site The server is built from the same files as this site, and redeployed whenever they change, so it serves the standard exactly as the site publishes it. A contract from `get-contract` is the same file as its `/raw/docs/.yaml`, and a page from the resources is the same as its `/raw/docs/.md`. ## Tools, prompts and resources ### Tools Your agent calls these on its own, following the instructions the server gives it. Every tool is read-only, and tells your client so. | Tool | What it returns | Arguments | | ------------------------------ | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | `list-components` | What the standard covers, shipped or planned, with the name the other tools take, like `button` | Optionally, `status` | | `get-contract` | The contract of a component or foundation, as YAML: its API, its DOM contract, its tokens and every rule | `component` | | `list-rules` | Rules as records, each with its ID, level, scope, requirement and how to check it, 50 at a time | Optionally, `component`, `ids`, `scope`, `layer`, `level`, `check` or `automated`, and `offset` and `limit` | | `get-page` | A page, or only the sections you name, with the examples in one framework | `path`, and optionally `sections` and `framework` | | `search-docs` | The sections and rules that best match a query, across the whole standard | `query`, and optionally `component`, `type` and `limit` | | `get-reference-implementation` | A component's reference implementation in Vue 3 and its tests, or only the files you name | `component`, and optionally `files` | A page longer than about 8,000 tokens, like the Button's, comes back as its outline, every heading with its anchor and length, so your agent can ask for the sections it needs. `list-rules` returns up to 50 rules at a time, with `total` counting every rule that matches and `nextOffset` when there are more, which your agent passes as `offset` to get the next page. No reply is longer than about 36,000 characters, so clients like Gemini CLI and Codex pass it on whole. ### Prompts Prompts are requests you start yourself, usually as slash commands. They ask for what the prompts on our pages ask for, then point your agent at the tools. | Prompt | What it does | Arguments | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | | `build-component` | Builds a component in your framework, from its contract, reference implementation and tests, or for one on the Roadmap, following the Button's structure | `component`, and optionally `framework` | | `review-component` | Reviews the code of a component or foundation, reporting every rule as pass or fail with its ID and the evidence, then fixes the failures | `component`, and optionally `code` | | `review-usage` | Does the same for a screen that uses a component, against the rules the screen meets | `component`, and optionally `screen` | | `adopt-token-paths` | Renames your CSS variables to [token paths](https://opencomponents.dev/docs/foundations/design-tokens), then adds the Stylelint rule that keeps them that way | None | How you start one depends on your client: | Client | Command | | ----------- | ------------------------------------------------------------------------- | | Claude Code | `/mcp__open-components__build-component button react` | | Gemini CLI | `/build-component button --framework=react` | | VS Code | `/mcp.open-components.build-component`, which then asks for its arguments | Not every client runs them: Codex doesn't, and Continue, OpenCode and Zed can't pass their arguments yet, so only `adopt-token-paths` works there. Where yours doesn't, ask for the same in your own words, as in [What you can ask](https://opencomponents.dev/#what-you-can-ask), or install our [agent plugin](https://opencomponents.dev/docs/getting-started/agent-plugin), whose skills your agent picks up on its own. ### Resources Resources are files you attach to a conversation yourself, with `@` in Claude Code, or **Add Context > MCP Resources** in VS Code. They're every contract and every page, at the same URLs as on this site, `/raw/docs/.yaml` and `.md`, and the [contract schema](https://opencomponents.dev/schemas/contract.json){external=""}. ## Privacy and security The server only serves the standard, so there's little that can go wrong, but here's exactly what it does and doesn't do: - **It's read-only.** Its tools read the standard and change nothing, on our side or in your project. Your agent makes every change to your code itself, with the permissions you give it. - **It takes no credentials.** There's no account, API key or token, so there's nothing to set up, store or leak. - **It only sees what your agent sends it.** Each call carries a few arguments, like a component's name, a search query or the sections to read. The exception is the `code` and `screen` arguments of the review prompts, which send whatever you pass in. To keep your code on your machine, pass the paths of your files instead, like `src/components/Button.tsx`, and your agent reads them itself. - **It serves what the site publishes.** Everything it returns comes from the same files as this site, in its [public repository](https://github.com/uxfront-com/open-components){rel=""nofollow""}, so you can read any of it before your agent does. ## Troubleshooting ::accordion :::accordion-item{label="My client doesn't list the server, or its tools"} Check that the URL is exactly `https://mcp.opencomponents.dev/mcp`, with `/mcp` at the end, and that the client connects to it over HTTP, which some clients call streamable HTTP, rather than SSE. Then restart the client, or reload its window, since many only read their MCP settings when they start. Some clients keep remote servers under their own key. In `.vscode/mcp.json` and Visual Studio's `.mcp.json`, the server goes under `servers` rather than `mcpServers`, and Antigravity and Windsurf ask for a `serverUrl` rather than a `url`. Gemini CLI saves nothing when you add the server to a project from your home folder, so use `--scope user` there. ::: :::accordion-item{label="My client asks me to sign in"} The server takes no credentials, so there's nothing to sign in to. Choose none, which clients call **No sign in** , **No Authentication** or **None** , and if a command asks whether the server needs authentication, answer no. ::: :::accordion-item{label="My agent doesn't use the server"} Mention it by name, as in "using the open-components MCP server", at least the first time. In Claude and ChatGPT, check that it's turned on for the conversation, and in Visual Studio and JetBrains IDEs, that its tools are turned on for your agent (see [Editors](https://opencomponents.dev/#editors) ). ::: :::accordion-item{label="My client only runs local servers"} It can still reach this one through [mcp-remote](https://www.npmjs.com/package/mcp-remote){rel=""nofollow""}, which runs on your machine and passes every request on. Add it as a local server with this command: ```bash npx -y mcp-remote https://mcp.opencomponents.dev/mcp ``` ::: :::accordion-item{label="A page came back as an outline"} That's on purpose. A page longer than about 8,000 tokens comes back as its outline, so your agent can ask for the sections it needs rather than fill its context with the whole page. It usually does on its own, and naming your framework keeps only its examples. ::: :::accordion-item{label="The prompts don't show up"} Not every client supports MCP prompts. Codex doesn't, and Continue, OpenCode and Zed can't pass their arguments yet, so only `adopt-token-paths` works there. Ask for the same in your own words instead, as in [What you can ask](https://opencomponents.dev/#what-you-can-ask) : the tools are all your agent needs. Or install our [agent plugin](https://opencomponents.dev/docs/getting-started/agent-plugin) , whose skills your agent picks up on its own. ::: :::accordion-item{label="Opening the URL in a browser brings me here"} That's expected. The server answers MCP clients, and sends browsers to this page. ::: :::accordion-item{label="My agent doesn't support MCP"} It can still read the standard. Every page of these docs is published as markdown, every contract as YAML, and `/llms.txt` lists them all, as [For agents](https://opencomponents.dev/docs/getting-started/introduction#for-agents) in the introduction explains. ::: :::accordion-item{label="Can I run the server myself?"} Yes. It's in the `mcp/` folder of our [repository](https://github.com/uxfront-com/open-components){rel=""nofollow""} , and `pnpm dev:mcp` serves it on your machine, at `http://localhost:3100/mcp` by default. Our [contributing guide](https://github.com/uxfront-com/open-components/blob/main/.github/CONTRIBUTING.md#the-mcp-server){rel=""nofollow""} covers how it's built. ::: :: Still stuck? [Open an issue](https://github.com/uxfront-com/open-components/issues/new/choose){rel=""nofollow""} with your client, its version and what you tried, and we'll help you connect. # Agent Plugin Our agent plugin gives your agent the standard in one install: skills that build and review components the way the standard asks, and our [MCP server](https://opencomponents.dev/docs/getting-started/mcp-server), which they read it from. It follows [Agent Plugins](https://agent-plugins.org){rel=""nofollow""}, the open format for packaging skills and MCP servers, so the same plugin works in every client that supports it. [Install it](https://opencomponents.dev/#install-it) shows you how. The MCP server on its own gives your agent the standard as tools, but your agent still has to know when to reach for them, and the server's prompts only run when you start them. Skills are instructions your agent picks up on its own when a task calls for them. Ask it to build a dialog, or to review your checkout page, and it reads the contract, checks the work against every rule and cites each one by its ID, without being told how. The plugin is in [`plugins/open-components`](https://github.com/uxfront-com/open-components/tree/main/plugins/open-components){rel=""nofollow""} in our repository. It's free, with nothing to sign in to. ## Install it Most clients install it from our repository in one or two commands, from the marketplace file at its root, which lists the plugin. Then start a new session, so your agent picks up the skills. ### Claude Code ```bash claude plugin marketplace add uxfront-com/open-components claude plugin install open-components@open-components ``` That installs it for you, in every project. In a session, run the same as slash commands: ```text /plugin marketplace add uxfront-com/open-components /plugin install open-components@open-components ``` Its skills then start with `/open-components:`, as in `/open-components:build-component`, and `/mcp` lists the server as `plugin:open-components:open-components`. ### Codex ```bash codex plugin marketplace add uxfront-com/open-components codex plugin add open-components@open-components ``` Or run `/plugins` in Codex and install it from the marketplace's tab. Run `/mcp` to check that the server is connected. ### GitHub Copilot In Copilot CLI: ```bash copilot plugin marketplace add uxfront-com/open-components copilot plugin install open-components@open-components ``` In a session, use the same slash commands as in [Claude Code](https://opencomponents.dev/#claude-code). For Copilot's cloud agent on github.com, add the marketplace and the plugin to `.github/copilot/settings.json` in your repository. VS Code reads it too, and recommends the plugin to everyone who opens the repository: ```json { "extraKnownMarketplaces": { "open-components": { "source": { "source": "github", "repo": "uxfront-com/open-components" } } }, "enabledPlugins": { "open-components@open-components": true } } ``` ### VS Code Run **Chat: Install Plugin From Source** from the Command Palette and enter `https://github.com/uxfront-com/open-components`. VS Code finds the plugin in the repository and installs it, after asking you to trust the source. You can also add our repository to your marketplaces in `settings.json`, then search for `@agentPlugins` in the Extensions view and click **Install**: ```json { "chat.plugins.marketplaces": ["uxfront-com/open-components"] } ``` ### Cursor In the Cursor CLI, add our marketplace, then install the plugin from `/plugin`: ```bash agent plugin marketplace add https://github.com/uxfront-com/open-components.git ``` In the editor, copy the plugin into your local plugins folder, then run **Developer: Reload Window**: ```bash git clone --depth 1 https://github.com/uxfront-com/open-components.git /tmp/open-components cp -R /tmp/open-components/plugins/open-components ~/.cursor/plugins/local/open-components ``` ### Kiro Kiro calls plugins powers. Open the **Powers** panel, choose **Add Custom Power**, then **Import power from GitHub**, and enter the plugin's folder: ```text https://github.com/uxfront-com/open-components/tree/main/plugins/open-components ``` ### Hermes Agent ```bash hermes plugins install uxfront-com/open-components/plugins/open-components --enable ``` That downloads only the plugin's folder. Run `hermes plugins list` to check that it's enabled. ### OpenClaw ```bash openclaw plugins install open-components --marketplace uxfront-com/open-components ``` Run `openclaw plugins inspect open-components` to check it, which shows its format as Agent Plugins. ### OpenHands Under **Plugins**, click **Add plugin**, set **Source** to `github:uxfront-com/open-components` and **Path** to `plugins/open-components`, then click **Install**. With the SDK, pass the same as a plugin source: ```python PluginSource(source="github:uxfront-com/open-components", repo_path="plugins/open-components") ``` ### Other clients - **Any client that supports Agent Plugins** can load the plugin from a folder: clone our repository and point it at `plugins/open-components`. NanoClaw, for one, only takes templates from a folder, so copy it to `templates/open-components`, then run: ```bash ncl groups create --template open-components --name "Open Components" ``` - **Clients that run Agent Skills but not plugins** can still have the skills. This installs them in Claude Code, Codex, Cursor, Gemini CLI and dozens of other agents, from `/.well-known/skills/` on this site: ```bash npx skills add https://opencomponents.dev ``` :brIt leaves out the MCP server, so [connect it](https://opencomponents.dev/docs/getting-started/mcp-server#connect-it) yourself, or let the skills read the standard from this site. ## What it offers ### Skills Your agent picks a skill when your request matches what it's for. In most clients, you can also start one yourself, as a slash command like `/build-component`. | Skill | What it's for | | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `build-component` | Builds a component in your framework, from its contract, its reference implementation and its tests, or, for one on the [Roadmap](https://opencomponents.dev/docs/getting-started/roadmap), following the Button's structure | | `review-component` | Reviews a component against its contract, or your styles against [Design Tokens](https://opencomponents.dev/docs/foundations/design-tokens), reporting every rule as pass or fail with its ID and the evidence, then fixes the failures | | `review-usage` | Does the same for a screen that uses a component, against the rules the screen meets, like keeping one primary button per view | | `adopt-token-paths` | Renames your CSS variables to [token paths](https://opencomponents.dev/docs/foundations/design-tokens), then adds the Stylelint rule that keeps them that way | They ask for what the MCP server's [prompts](https://opencomponents.dev/docs/getting-started/mcp-server#prompts) ask for, under the same names, then follow the steps the prompts point your agent at. ### The MCP server The plugin connects our MCP server, at `https://mcp.opencomponents.dev/mcp`, under the name `open-components`. The [MCP Server](https://opencomponents.dev/docs/getting-started/mcp-server#what-it-offers) page covers its tools, prompts and resources. If you've already added the server yourself, remove that entry once the plugin is installed, so your agent doesn't see every tool twice. Where a client doesn't connect the MCP servers in a plugin, or can't reach ours, the skills read the same contracts and pages from this site, at their [`/raw`](https://opencomponents.dev/docs/getting-started/introduction#for-agents) URLs. ## An example Once the plugin is installed, just ask: ```text Review our Button against Open Components. ``` Your agent picks up `review-component`, reads the Button's contract with `get-contract`, and lists the rules the component itself meets with `list-rules`. Then it reports every rule as pass or fail, with its ID and the evidence from your code, and fixes the failures. # Design Tokens Design Tokens are the named decisions behind every component, such as its colours, sizes, spacing and corner radii. In CSS, they're custom properties, and since their names are all that people, tools and agents have to go on, the names matter as much as the values. The biggest problem is that a name like `--blue-2` doesn't say what it's for, and `--color-primary-text` can be read in more than one way. Is it the `primary-text` colour, or the `text` of `color-primary`? This page covers how every Open Components token is named, a convention we call **token paths**. We'll look at: - **Token paths** - how to name a token, and why with a double dash - **Semantic names** - how to name a token after what it's for - **Theme tokens** - the colours that every component shares - **Component variables** - how a component names its own variables - **Checking tokens** - how linters and agents can hold your tokens to all of this ## At a glance | Aspect | Tokens | | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | Convention | Token paths, as in `--{group}--{name}` | | Separators | `--` between groups, and `-` between the words inside a group or a name | | Characters | Lowercase letters and digits | | Names | Semantic, saying what a token is for rather than what it looks like | | Theme | `--color--{color}`, `--color--{color}-contrast`, `--color--{color}-text` and `--color--focus` | | Components | `--{component}--{name}`, or `--{component}--{part}--{name}` for a part, set on the component's root | | Tools | `{color.primary}` in DTCG, `color/primary` in Figma and `color.primary` in [Styleframe](https://styleframe.dev){rel=""nofollow""} | | Lint | Stylelint's `custom-property-pattern` | ## Token paths A token's name is its path, made of the groups it belongs to, followed by its own name. Groups are separated by a double dash, and the words inside a group or a name by a single dash. ```css :root { --color--primary: …; /* color › primary */ --color--primary-contrast: …; /* color › primary-contrast */ --font-size--sm: …; /* font-size › sm */ } .button { --button--icon--size: 1rem; /* button › icon › size */ } ``` We call this convention token paths because the double dash plays the part of the dot in a path. The path stays the same wherever your tokens live, so moving between your design files, your token files and your CSS only means swapping the separator: | Where | How it's written | | -------------------- | ----------------------- | | CSS | `--color--primary-text` | | Design tokens (DTCG) | `{color.primary-text}` | | Figma variables | `color/primary-text` | | Styleframe | `color.primary-text` | ### Groups Most tokens only need two levels, the first saying what they belong to, and the second what they are. - The first group is the token's scope. It's either a category, like `color`, `spacing`, `font-size` or `border-radius`, or a component, like `button`. - The last part is the token's name, like `primary`, `md` or `height`. - A variation of a token stays in its name. The label on the primary fill is `--color--primary-contrast`, and a darker shade would be `--color--primary-shade-50`, rather than `--color--primary--contrast` or `--color--primary--shade-50`. When a token belongs to a part or a state of a component, rather than to the component as a whole, give that part or state a group of its own, even for a single token. The button sizes its icons with `--button--icon--size`, a tooltip's arrow would have `--tooltip--arrow--size`, and a link's hover state `--link--hover--color`. Since the group is there from the first token, adding a second one later, like `--button--icon--color`, never means renaming the first. A CSS property stays in one piece, though. `font-size` is a single name, so the button's text is sized with `--button--font-size`, rather than `--button--font--size`, and the same goes for names like `border-radius` and `line-height`. ### Why a double dash The double dash is what makes a name readable in both directions, by people and by tools. - **It's unambiguous.** With single dashes, `--font-size-sm` could be `font.size-sm`, `font-size.sm` or `font.size.sm`, and a tool has no way to tell which. `--font-size--sm` can only be `font-size.sm`, so tools, and agents, can turn any name back into its path. It's the same reason the [DTCG format](https://www.designtokens.org/tr/2025.10/format/){rel=""nofollow""} doesn't allow dots in token names. - **It's searchable.** Searching for `--button--` finds every variable of the button and nothing else, while `--button-` would also find a button group's `--button-group--gap`. - **It doesn't clash.** Tailwind CSS names its theme variables with single dashes, like `--color-blue-500` and `--radius-lg`, and libraries built on it add their own, like daisyUI's `--color-primary`. A token path can never have the same name as one of them, so your tokens and theirs can share a page. ## Semantic names Name a token after what it's for, rather than what it looks like. `--color--primary` still makes sense after a rebrand and in dark mode, while `--color--indigo-600` doesn't. It's the same idea as the Button's [`color`](https://opencomponents.dev/docs/components/button#colours) [prop](https://opencomponents.dev/docs/components/button#colours), which describes an intent rather than a hue. Tokens come in up to three tiers, and each tier reads from the one before it: | Tier | What it holds | For example | Read by | | --------- | ----------------------------- | ------------------------------------- | -------------- | | Palette | Every value you have | `--color--indigo-600`, `--spacing--4` | The theme | | Theme | What each value is for | `--color--primary`, `--color--focus` | Components | | Component | One component's own decisions | `--button--height`, `--button--fill` | That component | The palette is optional. Our reference tokens skip it, and set the theme straight from values in the Tailwind CSS palette. What matters is that components only read theme tokens, so a new brand or a dark mode is a new set of theme values, and no component has to change. ```css /* Do: the button reads what the colour is for */ .button[data-color="primary"] { --button--fill: var(--color--primary); } /* Don't: it reads a colour from the palette, so a new brand means editing every component */ .button[data-color="primary"] { --button--fill: var(--color--indigo-600); } ``` ## Theme tokens Every component reads its colours from the same theme tokens, so a single theme styles them all. There are three tokens per colour, plus a focus colour: | Token | What it is | | --------------------------- | ----------------------------------------------------- | | `--color--{color}` | The colour itself, for fills and tints | | `--color--{color}-contrast` | Text and icons on top of the fill | | `--color--{color}-text` | Text and icons in that colour, straight on the page | | `--color--focus` | The focus ring, which is the same for every component | `{color}` is one of `primary`, `secondary`, `neutral`, `success`, `info`, `warning` and `error`, the same colours as the `color` prop. A component with `color="primary"` reads `--color--primary`, `--color--primary-contrast` and `--color--primary-text`. Each pairing has to stay readable. `-contrast` needs a contrast of 4.5:1 against its colour, `-text` needs 4.5:1 against the page ([WCAG 1.4.3](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html){rel=""nofollow""}), and `--color--focus` needs 3:1 against the page ([WCAG 1.4.11](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html){rel=""nofollow""}), in both light and dark mode. Components that mix these colours, like the Button's `soft` variant, check their own pairings on top of that. The tokens use `light-dark()`, which follows `color-scheme`, so a single token holds the value for both modes: ```css :root { --color--primary: light-dark(oklch(51.1% 0.262 276.966), oklch(67.3% 0.182 276.935)); --color--primary-contrast: light-dark(oklch(100% 0 0), oklch(14.1% 0.005 285.823)); --color--primary-text: light-dark(oklch(45.7% 0.24 277.023), oklch(78.5% 0.115 274.713)); /* …secondary, neutral, success, info, warning and error */ --color--focus: light-dark(oklch(51.1% 0.262 276.966), oklch(67.3% 0.182 276.935)); } ``` You'll find the full set, with values that pass in both modes, in the Button's [`tokens.css`](https://opencomponents.dev/docs/components/button#reference-implementation). ## Component variables A component names its own variables after itself, as `--{component}--{name}`, or `--{component}--{part}--{name}` for one of its parts, and sets them on its root: ```css .button { --button--height: 2.25rem; --button--radius: 0.5rem; --button--icon--size: 1rem; --button--fill: var(--color--neutral); } .button[data-size="sm"] { --button--height: 2rem; --button--radius: 0.375rem; } .button[data-color="primary"] { --button--fill: var(--color--primary); } ``` - Setting them on the root, rather than on `:root`, keeps them with the component. Each size or colour changes them with a single selector, and nothing leaks into the rest of the page. - Starting them with the component's name makes it clear who owns them, so `--button--fill` can never be mistaken for a theme token. - Their colours come from the theme tokens, so the component follows every theme on its own. To tune a component, set its variables from a class of yours rather than with an inline style, since inline styles can't be reused, can't respond to media queries and are blocked by a strict Content Security Policy. Components put their styles in the `components` [cascade layer](https://developer.mozilla.org/en-US/docs/Web/CSS/@layer){rel=""nofollow""}, and a style outside a layer beats a layered one whatever its specificity, so a single class is all it takes: ::framework-switcher #react ```tsx [GetStarted.tsx] import styles from "./GetStarted.module.css"; export function GetStarted() { return ( ); } ``` ```css [GetStarted.module.css] .pill { --button--radius: 999px; } ``` #vue ```vue ``` #svelte ```svelte ``` #angular ```angular-ts @Component({ selector: "app-get-started", imports: [Button], template: ``, styles: ` .pill { --button--radius: 999px; } `, }) export class GetStarted {} ``` #solid ```tsx [GetStarted.tsx] import styles from "./GetStarted.module.css"; export function GetStarted() { return ( ); } ``` ```css [GetStarted.module.css] .pill { --button--radius: 999px; } ``` #astro ```astro ``` #vanilla ```html ``` :: The layers follow the same order as Tailwind CSS (`theme`, `base`, `components` and `utilities`), so components win over its resets and give way to its utilities. Keep in mind that a reset of your own outside a layer, like `button { border-radius: 0 }`, would win too, so it's best to put your resets in the `base` layer. Every component page covers its own variables under Tokens, as the [Button](https://opencomponents.dev/docs/components/button#tokens) does. ## Checking tokens The naming can be checked automatically, and agents can check the rest against the contract below. ### Lint Stylelint's [`custom-property-pattern`](https://stylelint.io/user-guide/rules/custom-property-pattern/){rel=""nofollow""} rule checks every custom property against a pattern, without its leading `--`. This one accepts token paths and nothing else: ```json [.stylelintrc.json] { "rules": { "custom-property-pattern": [ "^[a-z0-9]+(-[a-z0-9]+)*(--[a-z0-9]+(-[a-z0-9]+)*)+$", { "message": "Name CSS variables as token paths, like --color--primary" } ] } } ``` It flags `--color-primary`, `--button-height`, `--Color--primary`, `--color---primary` and `--primary`, and it checks `var()` references as well as declarations. That means it flags other libraries' variables too, like Tailwind's, so run it on your theme and your components' styles. ### Described Here's the whole convention in one block, for agents to read before they write or review a token: ```yaml convention: token-paths standard: https://opencomponents.dev/docs/foundations/design-tokens format: "--{group}--{name}" # "--{group}--{part or state}--{name}" for a part or state, even with one token separators: { group: "--", word: "-" } pattern: "^--[a-z0-9]+(-[a-z0-9]+)*(--[a-z0-9]+(-[a-z0-9]+)*)+$" paths: css: "--color--primary" dtcg: "{color.primary}" figma: color/primary styleframe: color.primary theme: colors: [primary, secondary, neutral, success, info, warning, error] tokens: ["--color--{color}", "--color--{color}-contrast", "--color--{color}-text", "--color--focus"] components: "--{component}--{name}, or --{component}--{part}--{name}, set on the component's root" overrides: "A class of yours, outside a layer. Components style themselves in @layer components, in Tailwind's layer order." rules: https://opencomponents.dev/docs/foundations/design-tokens#checklist ``` It's also published on its own at [`/raw/docs/foundations/design-tokens.yaml`](https://opencomponents.dev/raw/docs/foundations/design-tokens.yaml){external=""}, with every rule from the checklist in place of the `rules` link. ### Prompts You can point your agent straight at this page. To move your variables over to token paths, you could use: ```text Rename our CSS variables to follow the Open Components token paths: https://opencomponents.dev/raw/docs/foundations/design-tokens.md List our token groups first, then rename every variable along with every var() that reads it, keeping the values as they are. Then add the Stylelint rule from the page, and fix anything it reports. ``` And to review them: ```text Review our theme and component styles against the Open Components tokens standard: https://opencomponents.dev/raw/docs/foundations/design-tokens.md For each rule in its checklist, report pass or fail with the rule ID and the evidence from our code. Then fix the failures. ``` ## Checklist Here's every rule in one place, along with where it comes from and how to check it. Your tokens meet the standard when they meet every **must**, and each **should** is expected unless you have a good reason not to follow it. The basis says where a rule comes from. Contrast rests on **WCAG** 2.2, at level AA, while the naming rules are our own, marked **Open Components**, since WCAG has nothing to say about how you name your variables. | Rule | Level | Requirement | Basis | Check | | ------------------------ | ------ | ----------------------------------------------------------------------------------------------------------------------------- | ----------------------- | ---------------- | | `tokens/token-paths` | Must | Variables are named as token paths, in lowercase, with `-` between words and `--` between groups | Open Components | Stylelint | | `tokens/semantic-names` | Must | Theme and component tokens say what they're for, rather than what they look like | Open Components | Review | | `tokens/theme-only` | Must | Components read theme tokens, and never palette tokens or raw colours | Open Components | Review | | `tokens/shared-theme` | Must | Components read the shared theme tokens, so a single theme styles them all | Open Components | Review | | `tokens/component-scope` | Must | A component's own variables start with its name, like `--button--`, and are set on its root | Open Components | Review | | `tokens/class-overrides` | Should | Components put their styles in the `components` cascade layer, so a class can set their variables without an inline style | Open Components | Review | | `tokens/contrast` | Must | `-contrast` has 4.5:1 against its colour, `-text` 4.5:1 against the page and `--color--focus` 3:1, in light and dark mode | WCAG 1.4.3, 1.4.11 (AA) | Contrast checker | | `tokens/groups` | Should | A part or state always gets its own group, like `--button--icon--size`, while a variation or a CSS property stays in one name | Open Components | Review | ## FAQ ::accordion :::accordion-item{label="Isn't the double dash a BEM modifier?"} Not here. In BEM, `--` marks a modifier, as in `.button--primary` . Token paths use it the way a path uses a dot, so `--color--primary` reads as the `primary` token in the `color` group, rather than a colour modified to be primary. BEM names classes and token paths name variables, so the two can live side by side. ::: :::accordion-item{label="Can I use token paths with Tailwind CSS?"} Yes. Tailwind reads any variable you put in parentheses, as in `bg-(--color--primary)`. If you'd like shorter utilities, you can map your tokens onto its theme: ```css @theme inline { --color-primary: var(--color--primary); } ``` With that, `bg-primary` sets `background-color: var(--color--primary)`, so your tokens stay the single source of truth. ::: :::accordion-item{label="Why three tokens per colour?"} Because one colour can't be readable on both sides. `--color--warning` is a bright yellow, so the text on top of it has to be dark, and on a white page, warning text needs a much darker shade than the fill. With `-contrast` and `-text` , each pairing gets a colour that passes on its own, in both light and dark mode. ::: :::accordion-item{label="How do I move from single-dash names?"} One group at a time. A script can't tell where a single-dash name's group ends, which is exactly the problem token paths solve, so start by listing your groups, like `color` and `button`. Then replace `--color-` with `--color--`, and so on, in declarations and `var()` references alike. With Perl, for example: ```sh perl -pi -e 's/--(color|button)-(?=[a-z])/--$1--/g' $(git grep -lE -e '--(color|button)-[a-z]') ``` The Stylelint rule above will then catch anything you've missed. ::: :: ## Sources - [Design Tokens Format Module](https://www.designtokens.org/tr/2025.10/format/){rel=""nofollow""}, for token paths and groups - [CSS Custom Properties for Cascading Variables](https://www.w3.org/TR/css-variables-1/){rel=""nofollow""} - [Styleframe: Variables](https://www.styleframe.dev/docs/api/variables){rel=""nofollow""}, for the dot notation and its double-dash output - [Figma: Overview of variables, collections and modes](https://help.figma.com/hc/en-us/articles/14506821864087-Overview-of-variables-collections-and-modes){rel=""nofollow""} - [Material Design 3: Design tokens](https://m3.material.io/foundations/design-tokens/overview){rel=""nofollow""}, for the tiers - [Tailwind CSS: Theme variables](https://tailwindcss.com/docs/theme){rel=""nofollow""} - [Stylelint: custom-property-pattern](https://stylelint.io/user-guide/rules/custom-property-pattern/){rel=""nofollow""} - [Web Content Accessibility Guidelines (WCAG) 2.2](https://www.w3.org/TR/WCAG22/){rel=""nofollow""} # Button Buttons let people act on what's in front of them, whether that's saving a form, sending a message, opening a dialog or deleting a file. They're the most common interactive component in any interface, and also the one we most often get wrong. Think of a `
` that you can't reach with the keyboard, a form that gets sent twice, or a spinner that throws your focus back to the top of the page. This page walks you through building one the right way. We'll look at: - **User Interface** (UI) - how a button looks - **User Experience** (UX) - how it behaves for every person and every input - **Developer Experience** (DX) - how developers work with it - **Agentic Experience** (AX) - how agents can read and verify it Every example on this page runs on our [reference implementation](https://opencomponents.dev/docs/components/button#reference-implementation), which does its part of every rule in the [checklist](https://opencomponents.dev/docs/components/button#checklist), so feel free to try things out as you read. The code under each example comes in React, Vue, Svelte, Angular, Solid, Astro and Vanilla, written for a Button with the same API in each. Pick yours from the Framework select above the sidebar. Vanilla is plain HTML and JavaScript, so it writes out the markup from the [DOM contract](https://opencomponents.dev/docs/components/button#deterministic). :button-playground ## At a glance | Aspect | Button | | -------- | --------------------------------------------------------------------------------------------------- | | Element | ` ``` #vue ```vue ``` #svelte ```svelte ``` #angular ```angular-html ``` #solid ```tsx ``` #astro ```astro ``` #vanilla ```html ``` ::: :: | Variant | Looks | Use it for | | --------- | --------------------------- | ------------------------------------------------------------------ | | `solid` | Filled | The main action of a view, so there's only one per view or group | | `outline` | A border and no fill | Secondary actions next to a solid one | | `soft` | A tinted fill | Secondary actions that need a bit more presence, and dense layouts | | `subtle` | A tinted fill with a border | The same as `soft`, with an edge that holds up on busy backgrounds | | `ghost` | Nothing until you hover it | Tertiary actions in toolbars, table rows and icon-only buttons | | `link` | Underlined text | Low-emphasis actions that sit among text | Keep in mind that `link` is only a look. A button with `variant="link"` still runs an action, so if you want it to navigate, give it an `href` as well. It's underlined even at rest, because its colour alone isn't enough to tell it apart from the text around it ([WCAG 1.4.1](https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html){rel=""nofollow""}), and we recommend thickening the underline on hover. ### Colours The `color` prop describes the intent behind an action rather than its hue. A name like `primary` will still make sense after a rebrand, while `blue` might not. The label is what carries the meaning, so people who can't tell the colours apart, or who browse with forced colours, still get the same message ([WCAG 1.4.1](https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html){rel=""nofollow""}). ::button-colors-example :::framework-switcher #react ```tsx {/* Every colour works with every variant. */} ``` #vue ```vue ``` #svelte ```svelte ``` #angular ```angular-html ``` #solid ```tsx {/* Every colour works with every variant. */} ``` #astro ```astro ``` #vanilla ```html ``` ::: :: | Colour | Intent | For example | | ----------- | -------------------------------------------------------- | --------------------------- | | `primary` | The main action, in your brand colour | Save, Publish, Continue | | `secondary` | A second brand colour, for actions you want to set apart | Upgrade, Try Pro | | `neutral` | Everything else, and the default | Cancel, Back, Edit, Filter | | `success` | Completes something in a positive way | Approve, Mark as done | | `info` | Helps or explains something | Take the tour, Show details | | `warning` | Goes ahead despite a risk | Publish anyway, Override | | `error` | Destroys something, or can't be undone | Delete, Remove, Revoke | Note that `secondary` doesn't mean a secondary action, even though its name sounds like it. For a secondary action, it's best to lower the variant instead, for example with `variant="outline"` next to a `solid` primary button. Every colour reaches a contrast of 4.5:1 in every variant, at rest, on hover and when pressed, in both light and dark mode (you'll find the details under [tokens](https://opencomponents.dev/docs/components/button#tokens)). ### Sizes The `size` prop scales the height, text, icons, padding and corner radius together, and `md` is the default. We recommend sticking to one size per region of the page, and changing it for the whole region rather than for individual buttons. ::button-sizes-example :::framework-switcher #react ```tsx {/* …sm, md, lg, xl */} ``` #svelte ```svelte ``` #angular ```angular-html ``` #solid ```tsx {/* …sm, md, lg, xl */} ``` #vanilla ```html ``` ::: :: | Size | Min. height | Text | Icon | Padding | Gap | Radius | | ---- | ----------- | ---- | ---- | ------- | --- | ------ | | `xs` | 24px | 12px | 14px | 8px | 4px | 6px | | `sm` | 32px | 14px | 16px | 12px | 8px | 6px | | `md` | 36px | 14px | 16px | 16px | 8px | 8px | | `lg` | 40px | 16px | 20px | 20px | 8px | 8px | | `xl` | 48px | 16px | 20px | 24px | 8px | 12px | Sizes are set in `rem`, and the table shows the values we recommend, on a 4px grid, at the default text size of 16px. Every size meets the 24 × 24px minimum from [WCAG 2.5.8](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html){rel=""nofollow""}, except for a short `link` label inside a sentence, which WCAG allows. On touch screens, each one also gets a 44 × 44px hit area without looking any bigger. The heights are minimums, so the button grows with larger text or with a label that wraps. ### Icons Icons are there to support the label, and they should only replace it in the few icon-only buttons that everyone recognises. You can use any icon library you like (the examples use [Lucide](https://lucide.dev){rel=""nofollow""}'s, like `PlusIcon`) and place the icons in the `leading` and `trailing` slots. ::button-icons-example :::framework-switcher #react ```tsx ``` #svelte ```svelte ``` #angular ```angular-html ``` #solid ```tsx ``` #vanilla ```html ``` ::: :: - A leading icon reinforces the action, like a plus for creating something or a bin for deleting it. A trailing icon hints at what comes next: a chevron opens a menu, an arrow moves you forward, and an arrow pointing out of a box takes you to another site. - Icons are decorative, so the slots wrap them in `aria-hidden="true"`. This way, screen readers announce the label once, instead of something like "plus icon, New project". - Only make a button icon-only when the icon is universally understood (think close, search, settings or more) and space is tight. The button still keeps its label, just visually hidden, because screen readers read it, voice control users say it out loud and agents use it to find the button. It's a good idea to show the label in a tooltip too. - In right-to-left layouts, remember to mirror the icons that point along the reading direction, such as arrows. ### States Each state has a look of its own, styled from the attribute that exposes it. That way, what people see and what assistive technologies report can never drift apart, and a button can't look disabled without actually being disabled. ::button-states-example :::framework-switcher #react ```tsx ``` #vue ```vue ``` #svelte ```svelte ``` #angular ```angular-html ``` #solid ```tsx ``` #astro ```astro ``` #vanilla ```html ``` ::: :: | State | Selector | Recommended look | | -------- | ---------------------------------------- | ------------------------------------------------------ | | Hover | `:hover`, inside `@media (hover: hover)` | An 8% state layer | | Active | `:active` | A 12% state layer | | Focus | `:focus-visible` | A 2px outline, 2px from the edge, in `--color--focus` | | Disabled | `:disabled` or `[aria-disabled="true"]` | 50% opacity, a `not-allowed` cursor and no state layer | | Loading | `[data-loading]` | A spinner over the content and a `progress` cursor | | Pressed | `[aria-pressed="true"]` | A 12% state layer | | Expanded | `[aria-expanded="true"]` | A 12% state layer | For hover and press, we recommend state layers, which lay the label's own colour over the background at 8% and 12%, much like the [state layers](https://m3.material.io/foundations/interaction/states/state-layers){rel=""nofollow""} in Material Design. A single rule covers all 42 combinations of colour and variant, and it keeps contrast predictable, since the tokens still pass 4.5:1 with the layers applied. None of the states change the button's size or position, so there's no bolder label on hover and no border that appears on focus. A target that moves under your pointer is easy to miss. You'll find how each state behaves under [Every state](https://opencomponents.dev/docs/components/button#every-state). ### Hierarchy and placement Try to keep a single primary action per view or group, and make it the only `solid` `primary` button. When every button competes for attention, none of them stands out. You can rank the rest with `variant`, using `outline` or `soft` for secondary actions and `ghost` or `link` for tertiary ones. ::button-hierarchy-example :::framework-switcher #react ```tsx ``` #vue ```vue ``` #svelte ```svelte ``` #angular ```angular-html ``` #solid ```tsx ``` #astro ```astro ``` #vanilla ```html ``` ::: :: - Keep the order the same across your product. In our examples, the primary action comes last, where the eye ends up, which is on the right in left-to-right layouts and on the left in right-to-left ones. - Keep the DOM order and the visual order the same. Properties like `flex-direction: row-reverse` and `order` move buttons around visually but not for the keyboard, which then jumps around unexpectedly ([WCAG 1.3.2](https://www.w3.org/WAI/WCAG22/Understanding/meaningful-sequence.html){rel=""nofollow""}, [2.4.3](https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html){rel=""nofollow""}). - We recommend leaving at least 8px between buttons, so that a slightly missed tap doesn't land on the one next to it. - Size buttons to fit their label. On narrow screens, stack them in the same order, and they'll stretch to full width on their own inside a flex column. The width is up to the layout, which is why the Button doesn't have a prop for it. ### Tokens The button reads three [theme tokens](https://opencomponents.dev/docs/foundations/design-tokens#theme-tokens) per colour, plus one focus colour that every component shares: | Token | Used for | | --------------------------- | ------------------------------------------------------------------------------------ | | `--color--{color}` | The `solid` fill, and the tint of `soft` and `subtle` | | `--color--{color}-contrast` | The label and icons on top of the fill | | `--color--{color}-text` | The label and icons on the page, for `outline`, `soft`, `subtle`, `ghost` and `link` | | `--color--focus` | The focus ring, which is the same for every component | Everything else is derived from these. We recommend mixing 12% of the fill into the page for the `soft` variant, and the label colour into borders and state layers. Our reference tokens come from the Tailwind CSS palette and pass 4.5:1 for every colour and variant, at rest, on hover and when pressed, on both white and zinc-900. If you change them, make sure to check the same pairs. ```css :root { --color--primary: light-dark(oklch(51.1% 0.262 276.966), oklch(67.3% 0.182 276.935)); --color--primary-contrast: light-dark(oklch(100% 0 0), oklch(14.1% 0.005 285.823)); --color--primary-text: light-dark(oklch(45.7% 0.24 277.023), oklch(78.5% 0.115 274.713)); /* …secondary, neutral, success, info, warning and error */ --color--focus: light-dark(oklch(51.1% 0.262 276.966), oklch(67.3% 0.182 276.935)); } ``` If you need to tune a button, you can set its [own variables](https://opencomponents.dev/docs/foundations/design-tokens#component-variables), such as `--button--height`, `--button--padding` or `--button--radius`, from a class. The Button's styles sit in the `components` cascade layer, so your class wins without any specificity tricks: ::framework-switcher #react ```tsx [GetStarted.tsx] import styles from "./GetStarted.module.css"; export function GetStarted() { return ( ); } ``` ```css [GetStarted.module.css] .pill { --button--radius: 999px; } ``` #vue ```vue ``` #svelte ```svelte ``` #angular ```angular-ts @Component({ selector: "app-get-started", imports: [Button], template: ``, styles: ` .pill { --button--radius: 999px; } `, }) export class GetStarted {} ``` #solid ```tsx [GetStarted.tsx] import styles from "./GetStarted.module.css"; export function GetStarted() { return ( ); } ``` ```css [GetStarted.module.css] .pill { --button--radius: 999px; } ``` #astro ```astro ``` #vanilla ```html ``` :: ## User Experience (UX) Now let's look at how a button behaves for every person, input and setting. A good button is [accessible](https://opencomponents.dev/docs/components/button#accessible), [predictable](https://opencomponents.dev/docs/components/button#predictable), designed in [every state](https://opencomponents.dev/docs/components/button#every-state) and [adaptive](https://opencomponents.dev/docs/components/button#adaptive). ### Accessible #### Use the native element A native ` {/* Don't: there's no role, focus or keyboard support until you rebuild them */}
Save
``` #vue ```vue
Save
``` #svelte ```svelte
Save
``` #angular ```angular-html
Save
``` #solid ```tsx {/* Do: you get the role, focus, keyboard and disabled state for free */} {/* Don't: there's no role, focus or keyboard support until you rebuild them */}
Save
``` #astro ```astro
Save
``` #vanilla ```html
Save
``` :: #### Name every button The label is also the button's accessible name. Screen readers announce it, voice control users say it out loud ("click Save"), and tests and agents use it to find the button. - An icon-only button keeps its label, only visually hidden, through `label` and `iconOnly`. Hidden text is still ordinary text, so it gets translated along with the rest of the page, which translation tools don't always do for `aria-label`. - If you name a button with `aria-label` or `aria-labelledby`, make sure the name includes the visible label, ideally at the start ([WCAG 2.5.3](https://www.w3.org/WAI/WCAG22/Understanding/label-in-name.html){rel=""nofollow""}). A button that shows "Send" but is named "Submit message" won't respond when someone says "click Send". - If there's extra context, put it in a description, which screen readers read after the name. You can do that with `aria-describedby`, pointing at text like "Deleting is permanent". #### Contrast and size - The label needs a contrast of at least 4.5:1 with its background, in every variant and state ([WCAG 1.4.3](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html){rel=""nofollow""}), and the focus ring needs 3:1 against the page ([WCAG 1.4.11](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html){rel=""nofollow""}). Disabled buttons are exempt, but they should still be readable. - Every button is at least 24 × 24px ([WCAG 2.5.8](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html){rel=""nofollow""}). On touch screens, the hit area grows to 44 × 44px ([WCAG 2.5.5](https://www.w3.org/WAI/WCAG22/Understanding/target-size-enhanced.html){rel=""nofollow""}), which is the size Apple recommends, while Material recommends 48dp. #### Show focus When you reach a button with the keyboard, it shows an outline in the same focus colour as every other component ([WCAG 2.4.7](https://www.w3.org/WAI/WCAG22/Understanding/focus-visible.html){rel=""nofollow""}). It needs to be at least 2px thick and drawn outside the button, and we recommend 2px for both. We use `:focus-visible`, so the ring appears for keyboard users but not on every click, and we draw it with `outline`, which survives forced colours mode where `box-shadow` doesn't. You'll also want to keep focused buttons clear of sticky headers and footers, which you can do with `scroll-padding` ([WCAG 2.4.11](https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-minimum.html){rel=""nofollow""}, [2.4.12](https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-enhanced.html){rel=""nofollow""}). #### Don't nest interactive elements HTML doesn't allow links, buttons, inputs or `tabindex` inside a button, or a button inside a link. Keyboards and screen readers can't reliably reach the inner control, and a single click ends up activating both. If you'd like a whole card to be clickable, stretch its title link over the card with a pseudo-element, and lift the card's buttons above it. ### Predictable #### Run actions on click Always run your actions on `click`. It fires for the mouse, touch, pen, `Enter`, `Space` and assistive technologies, and only when the pointer is released, so people can still change their mind by moving away before letting go ([WCAG 2.5.2](https://www.w3.org/WAI/WCAG22/Understanding/pointer-cancellation.html){rel=""nofollow""}). Events like `mousedown`, `pointerdown` and `touchstart` fire as soon as you press, and never for the keyboard. #### Keyboard | Key | Button | Link (`href`) | | ------------------ | -------------------------- | ---------------- | | `Tab`, `Shift+Tab` | Moves focus to and from it | The same | | `Enter` | Activates it | Follows the link | | `Space` | Activates it, on release | Scrolls the page | A link that looks like a button still behaves like a link, because that's what keyboard and screen reader users expect from the role they hear. #### Move focus where people expect it After a button is activated, focus should go where the [APG](https://www.w3.org/WAI/ARIA/apg/patterns/button/){rel=""nofollow""} recommends: - A button that opens a dialog moves focus into it, and closing the dialog brings focus back to the button. - A button that acts in place, like Apply, keeps its focus. - A button that starts a new step moves focus to the start of that step. - A button that removes its own content, like Delete in a list, moves focus somewhere that makes sense, such as the next item. Focus should never fall back to the top of the page. #### Write labels that say what happens - Start with a verb and name the object, as in "Save changes", "Delete project" or "Invite member", rather than "OK", "Yes", "Submit" or "Click here". - Keep labels short. We recommend sentence case, without a full stop. - Add an ellipsis (…) when the action needs more input before it happens, like "Save as…" or "Schedule…". - Use the same label for the same action everywhere ([WCAG 3.2.4](https://www.w3.org/WAI/WCAG22/Understanding/consistent-identification.html){rel=""nofollow""}). #### Make destructive actions recoverable ::button-destructive-example :::framework-switcher #react ```tsx ``` #vue ```vue ``` #svelte ```svelte ``` #angular ```angular-html ``` #solid ```tsx ``` #astro ```astro ``` #vanilla ```html ``` ::: :: - Use `color="error"` and name what's being destroyed, so "Delete project" rather than "Delete" or "Yes". - For everyday actions, offer undo rather than asking for confirmation. Save confirmations for what can't be undone, especially when data, money or legal commitments are involved ([WCAG 3.3.4](https://www.w3.org/WAI/WCAG22/Understanding/error-prevention-legal-financial-data.html){rel=""nofollow""}). - In a confirmation dialog, name both choices by their outcome, and don't give the destructive one the initial focus ([APG dialog](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/){rel=""nofollow""}). #### Don't submit forms by accident In HTML, a ` ``` #vue ```vue
``` #svelte ```svelte
``` #angular ```angular-html
``` #solid ```tsx
``` #astro ```astro
``` #vanilla ```html
``` :: ### Every state #### Disabled A disabled button can't be activated, and there are two ways to disable one: - `disabled` sets the native attribute. The button leaves the tab order, and screen readers announce it as dimmed or unavailable when they read through the page. - `disabled` with `focusableWhenDisabled` sets `aria-disabled="true"` instead. The button stays in the tab order, so keyboard and screen reader users can still find it, along with whatever explains why it's disabled, while the Button blocks activation on its own. Reach for `focusableWhenDisabled` whenever a button can be disabled while it has focus, like Next once you've paged to the end of a list. Native `disabled` would take its focus away and drop it on the ``, and keyboard and screen reader users would lose their place. For a button that's busy with its own action, use [`loading`](https://opencomponents.dev/docs/components/button#loading) instead, which keeps its focus on its own. We recommend disabling as little as you can. A disabled Submit button doesn't tell anyone what's missing, so it's usually better to keep it enabled, validate on submit and move focus to the first error. When a button really has to be disabled, explain why next to it, or in a tooltip on a focusable disabled button. Also, avoid faking a disabled state with `pointer-events: none`. Clicks fall through to whatever is underneath, tooltips can't open, and the keyboard can still activate the button. #### Loading A loading button is busy with the action it was just asked to do. It keeps its focus, size and name, it can't be activated again, and it lets screen readers know that it's busy. ::button-loading-example :::framework-switcher #react ```tsx import { useState } from "react"; export function SaveChanges() { const [saving, setSaving] = useState(false); const [status, setStatus] = useState(""); async function save() { setSaving(true); try { await api.save(form); setStatus("Changes saved"); } catch { setStatus("Couldn't save your changes. Try again."); } finally { setSaving(false); } } return ( <>

{status}

); } ``` #vue ```vue ``` #svelte ```svelte

{status}

``` #angular ```angular-ts import { Component, signal } from "@angular/core"; @Component({ selector: "app-save-changes", imports: [Button], template: `

{{ status() }}

`, }) export class SaveChanges { saving = signal(false); status = signal(""); async save() { this.saving.set(true); try { await api.save(form); this.status.set("Changes saved"); } catch { this.status.set("Couldn't save your changes. Try again."); } finally { this.saving.set(false); } } } ``` #solid ```tsx import { createSignal } from "solid-js"; export function SaveChanges() { const [saving, setSaving] = createSignal(false); const [status, setStatus] = createSignal(""); async function save() { setSaving(true); try { await api.save(form); setStatus("Changes saved"); } catch { setStatus("Couldn't save your changes. Try again."); } finally { setSaving(false); } } return ( <>

{status()}

); } ``` #astro ```astro

``` #vanilla ```html

``` ::: :: - It keeps its focus, because loading sets `aria-disabled="true"` instead of `disabled`. Disabling a focused button takes its focus away (Chrome moves it back to the ``), and keyboard and screen reader users lose their place. - It keeps its size and name. The spinner covers the content, which stays in place but turns transparent, so nothing around the button moves and the accessible name stays the same. - It can't run twice. Clicks, `Enter` and `Space` are ignored until loading ends, and so is `Enter` in a form field when the button is the form's first submit button, which means no double submissions and no double charges. - It's announced. When loading starts, the Button announces its `loadingLabel` through a polite live region. Announcing the outcome is up to the page, with a status message like the one above, or an alert for errors ([WCAG 4.1.3](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html){rel=""nofollow""}). #### Toggle buttons A toggle button switches something on and off, and tells assistive technologies which one it is with `aria-pressed`. A screen reader would announce it as "Mute, toggle button, pressed". ::framework-switcher #react ```tsx ``` #vue ```vue ``` #svelte ```svelte ``` #angular ```angular-html ``` #solid ```tsx ``` #astro ```astro ``` #vanilla ```html ``` :: Keep the label the same in both states, so it says "Mute" whether it's pressed or not. If you'd rather change the label, like Play and Pause, leave `aria-pressed` out, since the label already tells people the state. For a setting that stays on or off, like "Email notifications", a [switch](https://www.w3.org/WAI/ARIA/apg/patterns/switch/){rel=""nofollow""} is a better fit. #### Menu buttons A button that opens a menu says so with `aria-haspopup="menu"`, and uses `aria-expanded` to say whether the menu is open. We recommend a trailing chevron, which gives sighted users the same hint, while the menu itself takes care of the arrow keys and focus, as described in the [APG menu button](https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/){rel=""nofollow""} pattern. ::framework-switcher #react ```tsx ``` #vue ```vue ``` #svelte ```svelte ``` #angular ```angular-html ``` #solid ```tsx ``` #astro ```astro ``` #vanilla ```html ``` :: ### Adaptive #### Pointer, touch and pen - Hover styles only apply to pointers that can actually hover (`@media (hover: hover)`), otherwise a tap would leave the button looking hovered. - On touch screens, every button gets a hit area of at least 44 × 44px, drawn by an invisible `::before`, without changing how big it looks. - We recommend `touch-action: manipulation`, which stops a quick second tap from zooming the page, and a transparent `-webkit-tap-highlight-color`, which removes the grey flash on tap, since the button already draws its own pressed state. #### Motion When someone prefers reduced motion (`prefers-reduced-motion: reduce`), nothing turns or slides ([WCAG 2.3.3](https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions.html){rel=""nofollow""}). We recommend letting the spinner pulse instead of turning, so it still shows that the button is busy, which the [Spinner](https://opencomponents.dev/docs/components/spinner#motion) does on its own. #### More contrast When someone asks for more contrast (`prefers-contrast: more`), as with Increase contrast on macOS and iOS, the borders of `outline` and `subtle` buttons are drawn in the label's colour at full strength, rather than a tint of it. The labels already pass 4.5:1, so they stay as they are. #### Forced colours Windows contrast themes replace every colour with the user's own, and drop backgrounds, gradients and shadows. To stay visible, the Button keeps a transparent border, which the system draws as its outline, draws focus with `outline`, and marks its states with system colours. We recommend `GrayText` when it's disabled and `Highlight` when it's pressed or expanded. #### Colour scheme The tokens use `light-dark()`, which follows `color-scheme`, so the Button adapts to your light and dark themes on its own. We've checked the contrast in both. #### Text size and spacing Heights are minimums in `rem`, so the button grows with its text instead of clipping it. Labels wrap at 200% text size ([WCAG 1.4.4](https://www.w3.org/WAI/WCAG22/Understanding/resize-text.html){rel=""nofollow""}), with wider line, letter and word spacing ([WCAG 1.4.12](https://www.w3.org/WAI/WCAG22/Understanding/text-spacing.html){rel=""nofollow""}) and on screens as narrow as 320px ([WCAG 1.4.10](https://www.w3.org/WAI/WCAG22/Understanding/reflow.html){rel=""nofollow""}). A button's label should never be truncated. #### Languages and direction The Button uses logical properties and `leading` and `trailing` slots rather than left and right, so it mirrors itself in right-to-left languages. Translated labels are often much longer than English ones, which is one more reason to never give a button a fixed width. Remember to translate `loadingLabel` along with your labels, and to mark a label in another language with `lang`, so that screen readers pronounce it correctly ([WCAG 3.1.2](https://www.w3.org/WAI/WCAG22/Understanding/language-of-parts.html){rel=""nofollow""}). ::button-languages-example :::framework-switcher #react ```tsx
``` #vue ```vue
``` #svelte ```svelte
``` #angular ```angular-html
``` #solid ```tsx
``` #astro ```astro
``` #vanilla ```html
``` ::: :: The arrow above has a `flip-in-rtl` class, which mirrors it in right-to-left layouts. Give the same class to any other icon that points along the reading direction: ```css .flip-in-rtl:dir(rtl) { scale: -1 1; } ``` ## Developer Experience (DX) Next, let's look at how developers use the Button. It has one API, with the same names as every other component, and it's typed end to end. ::framework-switcher #react ```tsx import { Button } from "@/components/Button"; export function Settings() { return ( ); } ``` #vue ```vue ``` #svelte ```svelte ``` #angular ```angular-ts import { Component } from "@angular/core"; import { Button } from "./button"; @Component({ selector: "app-settings", imports: [Button], template: ``, }) export class Settings { save() { // … } } ``` #solid ```tsx import { Button } from "~/components/Button"; export function Settings() { return ( ); } ``` #astro ```astro --- import Button from "../components/Button.astro"; --- ``` #vanilla ```html ``` :: ### Consistent Once you've learned the Button's API, you already know most of every other component's. These names mean the same thing everywhere in Open Components: | Name | Kind | What it means | | --------------------- | ----- | ----------------------------------------- | | `variant` | Prop | The emphasis, from `solid` down to `link` | | `color` | Prop | The intent, from `primary` to `error` | | `size` | Prop | `xs`, `sm`, `md`, `lg` or `xl` | | `label` | Prop | The text content, when it's plain text | | `disabled` | Prop | The component can't be used | | `loading` | Prop | The component is busy with an action | | `leading`, `trailing` | Slots | Content before and after the main content | | `click` | Event | The component was activated | We recommend `variant="solid"`, `color="neutral"` and `size="md"` as the defaults, while `type` always defaults to `"button"`. We chose `neutral` so that a `primary` button is always a conscious decision, made once per view. ### Type-safe Props, events and slots are typed, so your editor can autocomplete them and your type checker (`tsc`, `vue-tsc`, `svelte-check`, `ngc` or `astro check`) catches mistakes like `variant="primary"`. | Prop | Type | Default | Description | | ----------------------- | -------------------------------------------------------------------------------- | ----------- | -------------------------------------------------------------------------- | | `variant` | `"solid" | "outline" | "soft" | "subtle" | "ghost" | "link"` | `"solid"` | How much attention the button asks for | | `color` | `"primary" | "secondary" | "neutral" | "success" | "info" | "warning" | "error"` | `"neutral"` | What the action means | | `size` | `"xs" | "sm" | "md" | "lg" | "xl"` | `"md"` | How big the button is | | `label` | `string` | | The label, when it's plain text. The default slot takes precedence over it | | `iconOnly` | `boolean` | `false` | Shows only the leading icon, while the label still names the button | | `type` | `"button" | "submit" | "reset"` | `"button"` | The native button type | | `href` | `string` | | Renders a link that looks like a button | | `disabled` | `boolean` | `false` | Blocks activation | | `focusableWhenDisabled` | `boolean` | `false` | Keeps a disabled button in the tab order | | `loading` | `boolean` | `false` | Blocks activation and shows a spinner, while keeping focus and size | | `loadingLabel` | `string` | `"Loading"` | Announced to screen readers when loading starts | | Event | Payload | Description | | ------- | ------------ | ---------------------------------------------------------------------------- | | `click` | `MouseEvent` | Fires when the button is activated, but never while it's disabled or loading | | Slot | Description | | ---------- | ----------------------------------------------------------------------- | | `default` | The label | | `leading` | An icon before the label, which is also the icon of an icon-only button | | `trailing` | An icon after the label | We also export `ButtonProps`, which comes in handy for wrappers and data-driven UIs: ::framework-switcher #react ```ts import type { ButtonProps } from "@/components/Button"; const actions: (ButtonProps & { id: string })[] = [ { id: "cancel", label: "Cancel", variant: "ghost" }, { id: "publish", label: "Publish", color: "primary" }, ]; ``` #vue ```ts import type { ButtonProps } from "@/components/Button.vue"; const actions: (ButtonProps & { id: string })[] = [ { id: "cancel", label: "Cancel", variant: "ghost" }, { id: "publish", label: "Publish", color: "primary" }, ]; ``` #svelte ```ts import type { ButtonProps } from "$lib/components/Button.svelte"; const actions: (ButtonProps & { id: string })[] = [ { id: "cancel", label: "Cancel", variant: "ghost" }, { id: "publish", label: "Publish", color: "primary" }, ]; ``` #angular ```ts import type { ButtonProps } from "./button"; const actions: (ButtonProps & { id: string })[] = [ { id: "cancel", label: "Cancel", variant: "ghost" }, { id: "publish", label: "Publish", color: "primary" }, ]; ``` #solid ```ts import type { ButtonProps } from "~/components/Button"; const actions: (ButtonProps & { id: string })[] = [ { id: "cancel", label: "Cancel", variant: "ghost" }, { id: "publish", label: "Publish", color: "primary" }, ]; ``` #astro ```ts import type { ComponentProps } from "astro/types"; import Button from "../components/Button.astro"; type ButtonProps = ComponentProps; const actions: (ButtonProps & { id: string })[] = [ { id: "cancel", label: "Cancel", variant: "ghost" }, { id: "publish", label: "Publish", color: "primary" }, ]; ``` :: ### Composable The root element is the `
``` #vue ```vue ``` #svelte ```svelte ``` #angular ```angular-html ``` #solid ```tsx ``` #astro ```astro ``` #vanilla ```html ``` :: #### Links and routers Passing an `href` renders an ``. For links that go through your router, give the Button the link's `href` and your router's click handler, which leaves modified clicks, like `Ctrl`-click to open a new tab, to the browser. Routers that handle every link on the page only need the `href`: ::framework-switcher #react ```tsx import { useHref, useLinkClickHandler } from "react-router"; export function SettingsLink() { const href = useHref("/settings"); const navigate = useLinkClickHandler("/settings"); return ( ); } ``` #vue ```vue ``` #svelte ```svelte ``` #angular ```angular-html Settings ``` #solid ```tsx {/* Solid Router handles every link on the page. */} ``` #astro ```astro ``` #vanilla ```html Settings ``` :: Since the Button doesn't know anything about your router, it works with any of them. #### With other components - Tooltips on icon-only buttons repeat the button's label. The hidden label is what names the button, and the tooltip simply shows that name. - Menus and popovers set `aria-haspopup`, `aria-expanded` and `aria-controls` on the Button, which passes them straight through. - The loading spinner is a [Spinner](https://opencomponents.dev/docs/components/spinner). The Spinner draws itself, while the Button places it over the content, sizes it with `--button--icon--size` and announces `loadingLabel`, since the Spinner stays hidden from screen readers. - A group of buttons is simply a flex container with a gap. A toolbar, where the arrow keys move between buttons, is a separate pattern. ### Controllable The Button doesn't keep any state of its own. Every state is a prop or an attribute that you control, so all of them are always within reach: | State | How you control it | | -------- | ---------------------------------------------------------------------------------------------------------------- | | Disabled | `disabled`, and `focusableWhenDisabled` | | Loading | `loading`, set when the action starts (see [Loading](https://opencomponents.dev/docs/components/button#loading)) | | Pressed | `aria-pressed`, bound to whether it's on | | Expanded | `aria-expanded`, bound to whether its popup is open | | Focus | A ref to its element, and `focus()` | ## Agentic Experience (AX) Finally, let's look at how agents read, build and check a button. They rely on the same accessibility tree as assistive technologies, and on a contract they can check the code against. ### Semantic Agents that drive a browser, through Playwright or a browser MCP server, read the page through its accessibility tree, which is made of roles, names and states. This is how they see the examples on this page: ```yaml - button "Cancel" - button "Schedule…" - button "Publish post" - button "Save" [disabled] - button "Mute" [pressed] - button "Options" [expanded] - button "Delete" - link "Read the introduction": - /url: /docs ``` A `
` with a click handler has no role, so neither agents nor screen readers see it as a button. A real button has a role, a name and states, so an agent can tell what pressing it will do, and find it by its name: ```ts await page.getByRole("button", { name: "Save changes" }).click(); ``` ### Described Here's the whole contract in one block, for agents to read before they build or review a button: ```yaml component: Button summary: Runs an action in place. Renders a link when given an href. standard: https://opencomponents.dev/docs/components/button element: button # a, with href role: button # link, with href props: variant: { type: [solid, outline, soft, subtle, ghost, link], default: solid } color: { type: [primary, secondary, neutral, success, info, warning, error], default: neutral } size: { type: [xs, sm, md, lg, xl], default: md } label: { type: string } iconOnly: { type: boolean, default: false } type: { type: [button, submit, reset], default: button } href: { type: string } disabled: { type: boolean, default: false } focusableWhenDisabled: { type: boolean, default: false } loading: { type: boolean, default: false } loadingLabel: { type: string, default: Loading } slots: default: The label. leading: An icon before the label, and the icon of an icon-only button. trailing: An icon after the label. events: click: MouseEvent. Never fires while disabled or loading. states: hover: ":hover, in @media (hover: hover)" active: ":active" focus: ":focus-visible" disabled: "[disabled], or [aria-disabled=true] when focusableWhenDisabled" loading: "[aria-disabled=true][data-loading]" pressed: "[aria-pressed=true]" expanded: "[aria-expanded=true]" keyboard: Enter: Activates it. Links too. Space: Activates it, on release. Buttons only. parts: [leading, label, trailing, spinner] # data-slot, in this order. The spinner is a Spinner: https://opencomponents.dev/docs/components/spinner tokens: theme: ["--color--{color}", "--color--{color}-contrast", "--color--{color}-text", "--color--focus"] variables: [--button--height, --button--padding, --button--gap, --button--font-size, --button--icon--size, --button--radius] layer: components # in Tailwind's order: theme, base, components, utilities rules: https://opencomponents.dev/docs/components/button#checklist ``` It's also published on its own at [`/raw/docs/components/button.yaml`](https://opencomponents.dev/raw/docs/components/button.yaml){external=""}, with every rule from the checklist in place of the `rules` link. It's about a tenth of this page's length, so it's the one to give agents that only need the rules, and not the reasoning behind them. Every rule has a stable ID, like `button/keep-focus`, so you and your agents can cite it in reviews and commits. ### Deterministic The same props always render the same markup, leaving out anything your framework adds for itself, like comments or scoping attributes. For example, here's a primary Button that's loading: ```html ``` And here's what it renders: ```html ``` - Props are mirrored as `data-*` attributes. `data-variant`, `data-color` and `data-size` are always there, while `data-icon-only` and `data-loading` only appear when they're set. - Parts are marked with `data-slot` and always come in the same order: `leading`, `label`, `trailing` and `spinner`, which is a [Spinner](https://opencomponents.dev/docs/components/spinner#deterministic). - States change attributes, never elements. The focused element is never replaced, so both focus and the screen reader's position survive every state change. - There's nothing random or generated, like IDs or timestamps, so the markup is the same on the server, on the client and in every snapshot. - Icon-only is always a prop, and never guessed from the content. ### Verifiable Every rule in the checklist can be checked, and about half of them automatically. #### Unit tests Every rule that the checklist checks with a unit test has one in `Button.test.ts`, which you'll find under [Reference implementation](https://opencomponents.dev/docs/components/button#reference-implementation). We run them on every change to this page, so the code you see always passes them. #### In the browser In the browser, you can run [axe](https://github.com/dequelabs/axe-core){rel=""nofollow""} to check names, contrast, target size and nesting, and use an [ARIA snapshot](https://playwright.dev/docs/aria-snapshots){rel=""nofollow""} to check what agents will see: ```ts import AxeBuilder from "@axe-core/playwright"; import { expect, test } from "@playwright/test"; test("the publish dialog's buttons", async ({ page }) => { await page.goto("/posts/new"); await expect(page.getByRole("dialog")).toMatchAriaSnapshot(` - button "Cancel" - button "Schedule…" - button "Publish post" `); const { violations } = await new AxeBuilder({ page }) .withTags(["wcag2a", "wcag2aa", "wcag21aa", "wcag22aa"]) .analyze(); expect(violations).toEqual([]); }); ``` Some things still need a human, so try the button with the keyboard alone, with a screen reader and at 200% zoom, and with forced colours, more contrast and reduced motion turned on. You can emulate all three in Chrome DevTools, under Rendering. #### During development Our reference implementation also warns you in the console as soon as a button renders without an accessible name, which is the most common button bug out there. #### Prompts You can point your agent straight at this page. To build a button, you could use: ::framework-switcher #react ```text Build a Button component for our React design system that meets the Open Components Button standard: https://opencomponents.dev/raw/docs/components/button.md Follow its API, its DOM contract and its tokens. Port its tests and make them pass. Then check your work against every rule in its checklist with a Component or Both scope, and cite the rule ID for any rule you can't meet. ``` #vue ```text Build a Button component for our Vue 3 design system that meets the Open Components Button standard: https://opencomponents.dev/raw/docs/components/button.md Follow its API, its DOM contract and its tokens. Port its tests and make them pass. Then check your work against every rule in its checklist with a Component or Both scope, and cite the rule ID for any rule you can't meet. ``` #svelte ```text Build a Button component for our Svelte 5 design system that meets the Open Components Button standard: https://opencomponents.dev/raw/docs/components/button.md Follow its API, its DOM contract and its tokens. Port its tests and make them pass. Then check your work against every rule in its checklist with a Component or Both scope, and cite the rule ID for any rule you can't meet. ``` #angular ```text Build a Button component for our Angular design system that meets the Open Components Button standard: https://opencomponents.dev/raw/docs/components/button.md Follow its API, its DOM contract and its tokens. Port its tests and make them pass. Then check your work against every rule in its checklist with a Component or Both scope, and cite the rule ID for any rule you can't meet. ``` #solid ```text Build a Button component for our Solid design system that meets the Open Components Button standard: https://opencomponents.dev/raw/docs/components/button.md Follow its API, its DOM contract and its tokens. Port its tests and make them pass. Then check your work against every rule in its checklist with a Component or Both scope, and cite the rule ID for any rule you can't meet. ``` #astro ```text Build a Button component for our Astro design system that meets the Open Components Button standard: https://opencomponents.dev/raw/docs/components/button.md Follow its API, its DOM contract and its tokens. Port its tests and make them pass. Then check your work against every rule in its checklist with a Component or Both scope, and cite the rule ID for any rule you can't meet. ``` #vanilla ```text Build a Button for our design system in plain HTML, CSS and JavaScript that meets the Open Components Button standard: https://opencomponents.dev/raw/docs/components/button.md Follow its DOM contract and its tokens. Port its tests and make them pass. Then check your work against every rule in its checklist with a Component or Both scope, and cite the rule ID for any rule you can't meet. ``` :: To review one, the contract is enough: ```text Review our Button against the Open Components Button contract: https://opencomponents.dev/raw/docs/components/button.yaml For each rule with a component or both scope, report pass or fail with the rule ID and the evidence from our code. Then fix the failures. ``` And to review how a screen uses its buttons: ```text Review how our checkout page uses its buttons against the Open Components Button contract: https://opencomponents.dev/raw/docs/components/button.yaml For each rule with a usage or both scope, report pass or fail with the rule ID and the evidence from our code. Then fix the failures. ``` ## Checklist Here's every rule in one place, along with where it comes from and how to check it. A button meets the standard when it meets every **must**, and each **should** is expected unless you have a good reason not to follow it. The scope says who meets a rule. **Component** rules are met by the Button itself, so they're the ones to check when you build or review one. **Usage** rules are met by the screens that use it, like keeping one primary action per view, so they're the ones to check when you review a screen. **Both** rules need the two to work together, like naming an icon-only button: the Button keeps its label as hidden text, as long as you give it one. The basis says where a rule comes from. About half the rules rest on **WCAG** 2.2, which we follow at level AA, and name the success criteria behind them, and a few come from **HTML**, **WAI-ARIA** or the **APG**. The rest are **Open Components** rules, which are our own, like never changing a button's size between states. Where a rule goes further than WCAG asks at level AA, its basis says so: some come from level **AAA**, like the 44 × 44px hit area on touch screens, and a rule that asks for more than its criterion says **beyond**, like the underline on `link` buttons, which WCAG would let you swap for a 3:1 contrast with the text around it. ### UI rules | Rule | Level | Scope | Requirement | Basis | Check | | ------------------------ | ------ | --------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ---------------------- | | `button/intent-colors` | Must | Both | Colours describe the action's intent rather than a hue, and never carry that meaning on their own | WCAG 1.4.1 (A), Open Components | Review | | `button/one-primary` | Should | Usage | There's one solid primary button per view or group, and the rest are ranked with `variant` | Open Components | Review | | `button/contrast` | Must | Component | The label has a contrast of 4.5:1 at rest, on hover and when pressed, in every variant, and the focus ring has 3:1 against the page | WCAG 1.4.3, 1.4.11 (AA) | axe `color-contrast` | | `button/link-underlined` | Must | Component | The `link` variant is underlined at rest, so it doesn't rely on colour alone to stand out from the text around it | Open Components, beyond WCAG 1.4.1 (A) | Review | | `button/focus-visible` | Must | Component | Keyboard focus shows an `outline` at least 2px thick, offset from the button, on `:focus-visible` | WCAG 2.4.7 (AA), 2.4.13 (AAA) | Keyboard | | `button/target-size` | Must | Component | The button is at least 24 × 24px, unless it's a `link` inside a sentence, with a 44 × 44px hit area on touch screens | WCAG 2.5.8 (AA), 2.5.5 (AAA) | axe `target-size` | | `button/resizable` | Must | Component | The button is sized with `min-height` and padding in `rem`, and its label wraps instead of being clipped or truncated | Open Components, beyond WCAG 1.4.4, 1.4.10, 1.4.12 (AA) | 200% zoom | | `button/stable-size` | Must | Component | No state changes the button's size or position | Open Components | Visual regression test | | `button/state-selectors` | Should | Component | States are styled from the attributes that expose them | Open Components | Review | | `button/hover-capable` | Should | Component | Hover styles only apply inside `@media (hover: hover)` | Open Components | Review | | `button/forced-colors` | Must | Component | The button's boundary, focus, disabled and pressed states stay visible in forced colours mode | Open Components | Emulation | | `button/more-contrast` | Should | Component | Borders are drawn at full strength when `prefers-contrast: more` is set | Open Components | Emulation | | `button/reduced-motion` | Must | Component | Nothing turns or slides when `prefers-reduced-motion: reduce` is set | WCAG 2.3.3 (AAA) | Emulation | ### UX rules | Rule | Level | Scope | Requirement | Basis | Check | | --------------------------- | ------ | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------- | --------------------------------- | | `button/native-element` | Must | Component | It's a native ` ``` #vue ```vue ``` #svelte ```svelte ``` #angular ```angular-html Read the introduction ``` #solid ```tsx ``` #astro ```astro ``` #vanilla ```html Read the introduction ``` ::: :: ::framework-switcher #react ```tsx {/* Do: use a link for navigation, even when it looks like a button */} {/* Don't: this can't be opened in a new tab, and screen readers won't list it as a link */} ``` #vue ```vue ``` #svelte ```svelte ``` #angular ```angular-html See pricing ``` #solid ```tsx {/* Do: use a link for navigation, even when it looks like a button */} {/* Don't: this can't be opened in a new tab, and screen readers won't list it as a link */} ``` #astro ```astro ``` #vanilla ```html See pricing ``` :: ::accordion :::accordion-item{label="Should a button have a pointer cursor?"} Our reference implementation uses `cursor: pointer` , since that's the convention on the web and people read the hand as "this does something". Operating systems use the arrow for their own buttons, though, and some design systems follow them. Either choice is fine, as long as all your buttons agree. What matters more is the cursor over disabled ( `not-allowed` ) and loading ( `progress` ) buttons. ::: :::accordion-item --- label: Why not disable the submit button until the form is valid? --- Because a disabled button can't explain itself. People don't know which field is wrong, keyboard users can't focus it to find out, and screen reader users might not find it at all. It's better to keep it enabled, validate on submit and move focus to the first error, with a message next to each field. ::: :::accordion-item{label="Can I use ?"} You can, but its label lives in an attribute, so it can't hold icons or a spinner. A `

{checking && } {status}

); } ``` #vue ```vue ``` #svelte ```svelte

{#if checking}{/if} {status}

``` #angular ```angular-ts import { Component, signal } from "@angular/core"; @Component({ selector: "app-check-for-updates", imports: [Button, Spinner], template: `

@if (checking()) { } {{ status() }}

`, }) export class CheckForUpdates { checking = signal(false); status = signal(""); async check() { this.checking.set(true); this.status.set("Checking for updates"); try { this.status.set((await api.checkForUpdates()) ? "An update is available" : "You're up to date"); } catch { this.status.set("Couldn't check for updates. Try again."); } finally { this.checking.set(false); } } } ``` #solid ```tsx import { Show, createSignal } from "solid-js"; export function CheckForUpdates() { const [checking, setChecking] = createSignal(false); const [status, setStatus] = createSignal(""); async function check() { setChecking(true); setStatus("Checking for updates"); try { setStatus((await api.checkForUpdates()) ? "An update is available" : "You're up to date"); } catch { setStatus("Couldn't check for updates. Try again."); } finally { setChecking(false); } } return ( <>

{status()}

); } ``` #astro ```astro

``` #vanilla ```html

``` ::: :: - The status message is in the page from the start, empty, so screen readers announce "Checking for updates" as soon as it appears, and then the outcome ([WCAG 4.1.3](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html){rel=""nofollow""}). - The text is what people read, and what screen readers and agents find, so the Spinner can stay silent. - The spinner goes with the message here, since the message says more than the button's label would. The Button is disabled while it checks, and keeps its focus thanks to `focusableWhenDisabled`. When the button's own label says enough, its [`loading`](https://opencomponents.dev/docs/components/button#loading) state does all of this for you, spinner and announcement included. #### Keep focus where it is Never show a spinner in place of the element that has focus. Think of a "Load more" button that turns into a spinner when you press it. The button leaves the page, focus falls back to the ``, and keyboard and screen reader users lose their place. Show the spinner next to the button instead, or give the button `loading`, which keeps its focus. #### Contrast The indicator is what tells people that something's happening, so it needs a contrast of 3:1 against what's behind it ([WCAG 1.4.11](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html){rel=""nofollow""}). It's drawn in the colour of the text around it, so wherever that text passes 4.5:1, the indicator passes too. The track only shows the path, so it doesn't need to. With reduced motion, the pulse fades the whole spinner to 40% and back, so it only reaches that contrast at the brighter end of each pulse. ### Predictable #### Use it for waits of unknown length A spinner says that something's happening, but not how long it'll take. That's fine for a few seconds, and frustrating for much longer. We recommend: | When the wait is… | Show | | ------------------------------------------------------ | ----------------------------------------------------------------------- | | Under a second | Nothing. A spinner that flashes on and off distracts more than it helps | | A few seconds, of unknown length | A Spinner, where the thing that's loading is | | Longer than 10 seconds, or of a length you can measure | A progress bar, like ``, that shows how much is done | These follow the [guidance](https://www.nngroup.com/articles/progress-indicators/){rel=""nofollow""} from Nielsen Norman Group, which recommends no indicator at all for waits under a second, and reserves looped animations, like a spinner, for waits of 2 to 10 seconds. #### One spinner per wait Show one spinner for each thing that's loading, right where it's loading. A loading Button already shows one, so the page doesn't need another, and a list that loads its rows shows one for the list, rather than one for every row. #### Don't move the page A spinner shouldn't push anything around when it appears or disappears, because content that moves is easy to lose your place in, and a target that moves under your pointer is easy to miss. The Spinner does its part: it's a square of a fixed size, and it turns with `rotate`, which doesn't take up any more room. The rest is up to where you put it, so give it room that's already there. The Button lays its spinner over its content, and the status message in the example above is in the page from the start, wide enough for every message. ### Every state The Spinner has no states of its own, since it's how other components show theirs. What it shows is a wait, though, and every wait goes through the same few states, which are up to the page to design. #### Busy Something's loading. Show the spinner where it's loading, next to text that says what, and leave everything else where it was. Whatever started the wait keeps its focus and can't start it again, which a loading Button takes care of on its own. #### Done Take the spinner out as soon as the wait ends, and say how it went in the same status message, as in "You're up to date" or "12 results". Leave focus where it was, unless the wait was for something people go straight to, like the next step of a form, in which case move focus to its start. #### Failed Take the spinner out here as well, and say what went wrong and what people can do about it, as in "Couldn't check for updates. Try again." A status message is polite, so screen readers wait until they've finished what they're reading, which suits most errors. For one that needs attention straight away, use `role="alert"` instead. Never leave the spinner turning after a failure, because there's nothing left to wait for, and the page looks as if it has frozen. #### Taking longer than expected When a wait runs past 10 seconds, a spinner on its own stops reassuring people. Update the message to say what's going on, like "Still checking. This can take up to a minute.", and offer a way to cancel if you can. Change the message rarely, though. Screen readers read out every change, so a counter that ticks every second would never stop talking. ### Adaptive #### Motion When someone prefers reduced motion (`prefers-reduced-motion: reduce`), the spinner stops turning ([WCAG 2.3.3](https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions.html){rel=""nofollow""}), but it still needs to show that something's happening, since a still circle looks as if the page has frozen. We recommend a pulse, which changes the spinner's opacity without moving it, and ours fades to 40% and back every 1.6 seconds. #### More contrast When someone asks for more contrast (`prefers-contrast: more`), nothing needs to change. The indicator is already drawn in the text's own colour at full strength, and the track only shows the path. #### Forced colours Windows contrast themes replace every colour with the user's own. The Spinner is drawn in `currentColor`, so it takes the system's text colour along with the text around it, and stays visible without any styles of its own. In a disabled or loading Button, that's `GrayText`. #### Colour scheme The Spinner has no colours of its own, so it follows your light and dark themes along with the text around it. #### Text size It's sized in `em`, so it grows with the text around it at 200% text size ([WCAG 1.4.4](https://www.w3.org/WAI/WCAG22/Understanding/resize-text.html){rel=""nofollow""}), and stays as big as the text it sits next to. Components that size it in `rem`, like the Button, grow it along with everything else. #### Languages and direction The Spinner has no text of its own, so there's nothing in it to translate. The text that says what's loading belongs to the page, so remember to translate it along with the rest, and the Button's `loadingLabel` too. The spinner turns clockwise in every language, the way a clock does, so there's nothing to mirror in right-to-left layouts either. ## Developer Experience (DX) Next, let's look at how developers use the Spinner. It has no props, events or slots. You render it while something's loading, next to the text that says what, and it takes everything else from where it sits. ::framework-switcher #react ```tsx import { Spinner } from "@/components/Spinner"; export function DraftStatus({ saving }: { saving: boolean }) { return (

{saving && ( <> Saving draft )}

); } ``` #vue ```vue ``` #svelte ```svelte

{#if saving} Saving draft{/if}

``` #angular ```angular-ts import { Component, input } from "@angular/core"; import { Spinner } from "./spinner"; @Component({ selector: "app-draft-status", imports: [Spinner], template: `

@if (saving()) { Saving draft }

`, }) export class DraftStatus { saving = input(false); } ``` #solid ```tsx import { Show } from "solid-js"; import { Spinner } from "~/components/Spinner"; export function DraftStatus(props: { saving: boolean }) { return (

Saving draft

); } ``` #astro ```astro --- import Spinner from "../components/Spinner.astro"; ---

``` #vanilla ```html

``` :: ### Consistent The Spinner follows the same conventions as every other component. Its styles sit in the `components` cascade layer, its variable is a [token path](https://opencomponents.dev/docs/foundations/design-tokens#token-paths), and its parts are marked with `data-slot`. It also leaves a few props out, on purpose. There's no `size` prop, since the size of the text around it is almost always the right one, and the components that use it set their own. There's no `color` prop either, since a spinner has no intent of its own, so it can't be a `success` or an `error`. And there's no `label`, since whatever shows the spinner names the wait. If your design system needs a `size` prop anyway, give it the same values as every other component, from `xs` to `xl`, rather than a scale of its own. ### Type-safe With no props, events or slots, there's nothing of its own to type: `` is the whole API. Classes and attributes pass straight through to the ``. If you wrap it, type the wrapper's attributes as an ``'s, with the type your framework has for them. ### Composable The root element is the `` itself, so classes, styles and attributes you pass land directly on it. A component that shows a spinner does three things: - It marks the Spinner as one of its parts, with `data-slot`. - It places it, and sizes it with `--spinner--size`, from a selector that's more specific than `.spinner`. Both sit in the `components` layer, so the more specific one wins, whichever stylesheet loads first. - It announces the wait itself, since the Spinner stays silent. That's all the Button does in its [reference implementation](https://opencomponents.dev/docs/components/button#reference-implementation), along with hiding its content while the spinner covers it. While it's loading, it renders the Spinner as its last part, with `data-slot="spinner"`, and styles it from there: ```css @layer components { /* … */ .button[data-loading] > :not([data-slot="spinner"]) { opacity: 0; } .button > [data-slot="spinner"] { --spinner--size: var(--button--icon--size); position: absolute; inset: 0; margin: auto; } } ``` Every component that shows a spinner renders this one, rather than drawing its own. That way, you can change your spinner once and see it change everywhere, which is why the Button has no slot for it. ### Controllable The Spinner doesn't keep any state of its own. It turns for as long as it's in the page, so you show it by rendering it and hide it by taking it out, rather than with a prop. Once it's gone, there's nothing left animating. Everything else is within reach from outside: | What | How you control it | | ------ | ------------------------------------------------------------------- | | Shown | Render it while something's loading, and take it out once it's done | | Size | `--spinner--size`, from a class | | Colour | `color`, on the Spinner or on the text around it | ## Agentic Experience (AX) Finally, let's look at how agents read, build and check a spinner. They rely on the same accessibility tree as assistive technologies, which leaves the Spinner out, and on a contract they can check the code against. ### Semantic The Spinner isn't in the accessibility tree, so agents that read the page through it, with Playwright or a browser MCP server, don't see it at all. What they see is the text that says what's loading, which is one more reason to always show some. This is how they see the example under [Say what's loading](https://opencomponents.dev/docs/components/spinner#say-whats-loading), while it checks: ```yaml - button "Check for updates" [disabled] - status: Checking for updates ``` In a test, wait for that text rather than for the spinner: ```ts await expect(page.getByRole("status")).toHaveText("You're up to date"); ``` A spinner without any text around it leaves agents, like screen reader users, with nothing to wait for. ### Described Here's the whole contract in one block, for agents to read before they build or review a spinner: ```yaml component: Spinner summary: Shows that something is busy, for a wait of unknown length. Visual only. standard: https://opencomponents.dev/docs/components/spinner element: svg # aria-hidden="true", with no role or name: whatever shows it says what's loading props: {} # none: it takes its size and colour from the text around it parts: [track, indicator] # data-slot, in this order tokens: theme: [] # none: it's drawn in currentColor variables: [--spinner--size] # 1em by default layer: components # in Tailwind's order: theme, base, components, utilities rules: https://opencomponents.dev/docs/components/spinner#checklist ``` It's also published on its own at [`/raw/docs/components/spinner.yaml`](https://opencomponents.dev/raw/docs/components/spinner.yaml){external=""}, with every rule from the checklist in place of the `rules` link. It's a fraction of this page's length, so it's the one to give agents that only need the rules, and not the reasoning behind them. Every rule has a stable ID, like `spinner/say-what-loads`, so you and your agents can cite it in reviews and commits. ### Deterministic The Spinner always renders the same markup, leaving out anything your framework adds for itself, like comments or scoping attributes: ```html ``` - Its parts are marked with `data-slot` and always come in the same order: `track`, then `indicator`. - There's nothing random or generated. It's drawn with plain strokes rather than a gradient, which would need an `id`, so the markup is the same on the server, on the client and in every snapshot, however many spinners there are on the page. - Attributes you pass are added to the ``, like the Button's `data-slot="spinner"`. ### Verifiable Every rule in the checklist can be checked, and about a third of them automatically. #### Unit tests Every rule that the checklist checks with a unit test has one in `Spinner.test.ts`, which you'll find under [Reference implementation](https://opencomponents.dev/docs/components/spinner#reference-implementation). We run them on every change to this page, so the code you see always passes them. #### In the browser Most of the Spinner's rules are about the page around it, so they're best checked in the browser. You can hold a request back to look at the page while it waits, use an [ARIA snapshot](https://playwright.dev/docs/aria-snapshots){rel=""nofollow""} to check what agents and screen readers find, emulate reduced motion, and run [axe](https://github.com/dequelabs/axe-core){rel=""nofollow""} over the lot: ```ts import AxeBuilder from "@axe-core/playwright"; import { expect, test } from "@playwright/test"; test("checking for updates says what it's doing, and how it went", async ({ page }) => { await page.emulateMedia({ reducedMotion: "reduce" }); // Hold the check back until the test lets it go. let finish!: () => void; const finished = new Promise((resolve) => (finish = resolve)); await page.route("**/api/updates", async (route) => { await finished; await route.fulfill({ json: { available: false } }); }); await page.goto("/settings"); await page.getByRole("button", { name: "Check for updates" }).click(); // Agents and screen readers find the text, never the spinner, which pulses instead of turning. await expect(page.getByRole("status")).toMatchAriaSnapshot(`- status: Checking for updates`); await expect(page.locator(".spinner")).toHaveCSS("animation-name", /pulse/); const { violations } = await new AxeBuilder({ page }) .withTags(["wcag2a", "wcag2aa", "wcag21aa", "wcag22aa"]) .analyze(); expect(violations).toEqual([]); // Once it's done, the spinner is gone and the outcome is in its place. finish(); await expect(page.getByRole("status")).toHaveText("You're up to date"); await expect(page.locator(".spinner")).toHaveCount(0); }); ``` Some things still need a human. Try the page with a screen reader, and check that the spinner is never read out, while the page says what's loading and how it went. Then turn on forced colours, which you can emulate in Chrome DevTools under Rendering, and check that the spinner stays visible. #### Prompts You can point your agent straight at this page. To build a spinner, you could use: ::framework-switcher #react ```text Build a Spinner component for our React design system that meets the Open Components Spinner standard: https://opencomponents.dev/raw/docs/components/spinner.md Follow its API, its DOM contract and its tokens. Port its tests and make them pass. Then check your work against every rule in its checklist with a Component or Both scope, and cite the rule ID for any rule you can't meet. ``` #vue ```text Build a Spinner component for our Vue 3 design system that meets the Open Components Spinner standard: https://opencomponents.dev/raw/docs/components/spinner.md Follow its API, its DOM contract and its tokens. Port its tests and make them pass. Then check your work against every rule in its checklist with a Component or Both scope, and cite the rule ID for any rule you can't meet. ``` #svelte ```text Build a Spinner component for our Svelte 5 design system that meets the Open Components Spinner standard: https://opencomponents.dev/raw/docs/components/spinner.md Follow its API, its DOM contract and its tokens. Port its tests and make them pass. Then check your work against every rule in its checklist with a Component or Both scope, and cite the rule ID for any rule you can't meet. ``` #angular ```text Build a Spinner component for our Angular design system that meets the Open Components Spinner standard: https://opencomponents.dev/raw/docs/components/spinner.md Follow its API, its DOM contract and its tokens. Port its tests and make them pass. Then check your work against every rule in its checklist with a Component or Both scope, and cite the rule ID for any rule you can't meet. ``` #solid ```text Build a Spinner component for our Solid design system that meets the Open Components Spinner standard: https://opencomponents.dev/raw/docs/components/spinner.md Follow its API, its DOM contract and its tokens. Port its tests and make them pass. Then check your work against every rule in its checklist with a Component or Both scope, and cite the rule ID for any rule you can't meet. ``` #astro ```text Build a Spinner component for our Astro design system that meets the Open Components Spinner standard: https://opencomponents.dev/raw/docs/components/spinner.md Follow its API, its DOM contract and its tokens. Port its tests and make them pass. Then check your work against every rule in its checklist with a Component or Both scope, and cite the rule ID for any rule you can't meet. ``` #vanilla ```text Build a Spinner for our design system in plain HTML, CSS and JavaScript that meets the Open Components Spinner standard: https://opencomponents.dev/raw/docs/components/spinner.md Follow its DOM contract and its tokens. Port its tests and make them pass. Then check your work against every rule in its checklist with a Component or Both scope, and cite the rule ID for any rule you can't meet. ``` :: To review one, the contract is enough: ```text Review our Spinner against the Open Components Spinner contract: https://opencomponents.dev/raw/docs/components/spinner.yaml For each rule with a component or both scope, report pass or fail with the rule ID and the evidence from our code. Then fix the failures. ``` And to review how a screen shows its loading states: ```text Review how our search page uses its spinners against the Open Components Spinner contract: https://opencomponents.dev/raw/docs/components/spinner.yaml For each rule with a usage or both scope, report pass or fail with the rule ID and the evidence from our code. Then fix the failures. ``` ## Checklist Here's every rule in one place, along with where it comes from and how to check it. A spinner meets the standard when it meets every **must**, and each **should** is expected unless you have a good reason not to follow it. The scope says who meets a rule. **Component** rules are met by the Spinner itself, so they're the ones to check when you build or review one. **Usage** rules are met by the screens and components that show it, like saying what's loading, so they're the ones to check when you review a screen. **Both** rules need the two to work together, like contrast: the Spinner takes the colour of the text around it, as long as that text passes. The basis says where a rule comes from. A few rules rest on **WCAG** 2.2, which we follow at level AA, and name the success criteria behind them. The rest are **Open Components** rules, which are our own, like showing one spinner per wait. Where a rule goes further than WCAG asks at level AA, its basis says so: the reduced-motion rule comes from level **AAA**, and a rule that asks for more than its criterion says **beyond**, like announcing how a wait went, which WCAG only asks for when there's a message to announce. ### UI rules | Rule | Level | Scope | Requirement | Basis | Check | | ------------------------ | ------ | --------- | --------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | ---------------------- | | `spinner/em-sized` | Should | Component | It's `1em` by default, so it matches the text around it, and `--spinner--size` sets any other size | Open Components | Review | | `spinner/current-color` | Must | Component | It's drawn in `currentColor`, so it follows the text around it, every colour scheme and forced colours mode | Open Components | Emulation | | `spinner/contrast` | Must | Both | The indicator has a contrast of 3:1 against what's behind it | WCAG 1.4.11 (AA) | Contrast checker | | `spinner/reduced-motion` | Must | Component | It doesn't turn when `prefers-reduced-motion: reduce` is set, but still shows that something's happening, like with a pulse | WCAG 2.3.3 (AAA), Open Components | Emulation | | `spinner/stable-layout` | Must | Both | It keeps a fixed size while it turns, and showing or hiding it doesn't move anything around it | Open Components | Visual regression test | ### UX rules | Rule | Level | Scope | Requirement | Basis | Check | | -------------------------- | ------ | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | ---------------------------- | | `spinner/decorative` | Must | Component | It's hidden from assistive technologies with `aria-hidden`, and has no role or name of its own | WCAG 1.1.1 (A) | Unit test | | `spinner/say-what-loads` | Must | Usage | Whatever shows it says what's loading, in a status message that's already in the page or with a label it announces, like the Button's `loadingLabel` | WCAG 4.1.3 (AA) | ARIA snapshot, screen reader | | `spinner/announce-outcome` | Should | Usage | When the wait ends, the page says how it went, in the same status message, or in an alert for an error that needs attention straight away | Open Components, beyond WCAG 4.1.3 (AA) | Screen reader | | `spinner/keep-focus` | Must | Usage | Showing a spinner never takes focus away, so it never replaces the element that has focus | Open Components | Keyboard | | `spinner/remove-when-done` | Must | Usage | It's taken out as soon as the wait ends or fails, so it never turns with nothing to wait for | Open Components, beyond WCAG 2.2.2 (A) | Review | | `spinner/unknown-waits` | Should | Usage | It's for waits of a few seconds and of unknown length, while a progress bar shows longer or measurable ones | Open Components | Review | | `spinner/one-per-wait` | Should | Usage | There's one spinner for each thing that's loading, shown where it's loading | Open Components | Review | ### DX rules | Rule | Level | Scope | Requirement | Basis | Check | | ---------------------------- | ------ | --------- | --------------------------------------------------------------------------------------------- | --------------- | --------- | | `spinner/root-element` | Must | Component | The root element is the `` itself, so classes and attributes like `data-slot` land on it | Open Components | Unit test | | `spinner/stateless` | Should | Component | It has no props or state of its own, and it shows for as long as it's rendered | Open Components | Review | | `spinner/one-implementation` | Should | Usage | Components that show a spinner render the Spinner, rather than drawing one of their own | Open Components | Review | ### AX rules | Rule | Level | Scope | Requirement | Basis | Check | | --------------------------- | ------ | --------- | ----------------------------------------------------------------------- | --------------- | --------- | | `spinner/deterministic-dom` | Must | Component | It always renders the same markup, with nothing generated, like an `id` | Open Components | Unit test | | `spinner/data-attributes` | Should | Component | Its parts are marked with `data-slot`, in a fixed order | Open Components | Unit test | ## Reference implementation Our Vue 3 reference implementation does its part of every rule on this page, powers every example on it and is what the [Button](https://opencomponents.dev/docs/components/button#reference-implementation) shows while it's loading. Feel free to copy it into your design system and change how it looks, but try to keep how it behaves: hidden from assistive technologies, drawn in `currentColor`, `1em` by default and pulsing with reduced motion. The tests are in `Spinner.test.ts`. To run them, you'll need [Vitest](https://vitest.dev){rel=""nofollow""}, set to `environment: "jsdom"` and `globals: true`, along with `@testing-library/vue` and `@testing-library/jest-dom`. ::code-collapse :::code-group ```vue [Spinner.vue] ``` ```ts [Spinner.test.ts] import { render } from "@testing-library/vue"; import { describe, expect, it } from "vitest"; import Spinner from "./Spinner.vue"; describe("Spinner", () => { it("is hidden from assistive technologies, with no role or name of its own", () => { const { container } = render(Spinner); const spinner = container.querySelector("svg"); expect(spinner).toHaveAttribute("aria-hidden", "true"); expect(spinner).not.toHaveAttribute("role"); expect(spinner?.querySelector("title")).toBeNull(); }); it("renders the svg itself, so classes and attributes land on it", () => { const { container } = render(Spinner, { attrs: { class: "large", "data-slot": "spinner" } }); const spinner = container.firstElementChild; expect(spinner?.tagName).toBe("svg"); expect(spinner).toHaveClass("spinner", "large"); expect(spinner).toHaveAttribute("data-slot", "spinner"); }); it("renders the same markup every time, with its parts in a fixed order", () => { const first = render(Spinner).container.innerHTML; const { container } = render(Spinner); expect(container.innerHTML).toBe(first); const parts = [...container.querySelector("svg")!.children].map((part) => part.getAttribute("data-slot")); expect(parts).toEqual(["track", "indicator"]); }); }); ``` ::: :: ## FAQ #### Spinner, skeleton or progress bar? All three show that something is on its way, and each one suits a different wait: | When you're waiting for… | Use | Because | | -------------------------------------------------------- | ----------------------------------------------------------------------------- | --------------------------------------------------------------- | | An action people just started, like saving or sending | A loading [Button](https://opencomponents.dev/docs/components/button#loading) | It shows the wait where people acted, and keeps their focus | | Something of unknown length, in one part of the page | A Spinner, next to a status message | It shows that something's happening, right where it's happening | | Content whose layout you know, like a list or a card | A skeleton | It holds the content's place, so nothing moves when it arrives | | Something you can measure, or that takes over 10 seconds | A progress bar | It shows how much is done, and how much is left | Skeletons need the same care as the Spinner. They're hidden from assistive technologies, a status message says what's loading, and their shimmer stops when someone prefers reduced motion. A progress bar, on the other hand, has a role and a value of its own, so give it a name, like a `
` that draws the field | | Role | `textbox`, or `searchbox` with `type="search"`, while ARIA has no role for a password input | | Name | Its `