# Installation
> Install @9klabs/design, import its one stylesheet, load the brand fonts, and render your first component.
## Install the package
`@9klabs/design` is a Vue 3 library. It needs Vue 3.5 or later as a peer dependency.
```bash
npm install @9klabs/design
```
## Import the stylesheet once
Import the stylesheet at your application entry, before anything else renders:
```ts
// main.ts
import '@9klabs/design/style.css';
```
It is the only CSS the library needs. It carries the design tokens, the light and dark themes,
branded element defaults and accessibility utilities. Every component brings its own scoped styles,
so nothing else has to be imported per component.
The stylesheet declares its cascade layers up front, in this order:
```css
@layer normalize, fonts, tokens, theme, base, primitives, utilities;
```
Your own CSS, which is not in a layer, always wins over all of them. Override a token or restyle an
element without fighting specificity.
## Load the brand fonts
The design uses IBM Plex Sans and IBM Plex Serif for English, and Thmanyah Sans and Thmanyah Serif
Display for Arabic. The main stylesheet names those families but does not bundle the font files,
because bundling them would add more than a megabyte to every app.
Choose one of the following:
- **Bundled copies.** Import them next to the stylesheet:
```ts
import '@9klabs/design/style.css';
import '@9klabs/design/font-faces.css';
```
- **Your own loader.** Provide the families yourself, for example with `@nuxt/fonts`, which resolves
them from the `--font-sans`, `--font-serif`, `--font-arabic-sans` and `--font-arabic-display`
tokens.
## Use a component
Import components by name from the package root:
```vue
Save changesCancel
```
That renders:
```vue
Save changesCancel
```
Types are exported from the same entry point, for example `I9kComponentSize`, `I9kTabItem` and
`I9kBreadcrumbItem`:
```ts
import type { I9kTabItem } from '@9klabs/design';
```
## Next steps
- [Vue Router and Nuxt](https://design.the9klabs.com/getting-started/frameworks.md): render links through your router and keep
server rendering safe.
- [Theming and dark mode](https://design.the9klabs.com/foundations/theming.md): switch between light and dark.
- [Components](https://design.the9klabs.com/components.md): browse all of them.
# Vue Router and Nuxt
> Route links through Vue Router or Nuxt, install the toaster per app, and keep server rendering safe.
## Links through your router
Components that render navigation take a `linkComponent` prop. Without it, a link is a plain ``
and following it reloads the page. With it, the component renders yours instead and passes the
destination as `to`.
These components take `linkComponent`:
| Component | Renders links for |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- |
| [`I9kButton`](https://design.the9klabs.com/components/button.md), [`I9kIconButton`](https://design.the9klabs.com/components/icon-button.md) | `to` |
| [`I9kNavigation`](https://design.the9klabs.com/components/navigation.md), [`I9kNavMenu`](https://design.the9klabs.com/components/nav-menu.md) | the site links |
| [`I9kSideNav`](https://design.the9klabs.com/components/side-nav.md) | the section links |
| [`I9kBreadcrumb`](https://design.the9klabs.com/components/breadcrumb.md) | every item but the last |
| [`I9kFooter`](https://design.the9klabs.com/components/footer.md) | site-relative links in its columns |
| [`I9kAnnouncementBar`](https://design.the9klabs.com/components/announcement-bar.md), [`I9kProfileMenu`](https://design.the9klabs.com/components/profile-menu.md), [`I9kLanguageSwitcher`](https://design.the9klabs.com/components/language-switcher.md) | their links |
Pass the imported component, not its name, so it resolves wherever the component renders:
```vue
```
`I9kButton` and `I9kIconButton` also accept the globally registered name, which is the shortest way
to write a one-off link:
```vue
See pricing
```
In Nuxt, pass `NuxtLink` the same way. It is auto-imported in templates, so
`:link-component="NuxtLink"` needs no import in a page or component.
::: tip Decide the current page in the caller
`I9kSideNav` and `I9kBreadcrumb` never read the router. Set `current` on a side-nav link, or put
the current page last in the breadcrumb, from your route. That way a nested page, such as an editor
under Courses, can still mark its section.
:::
## Notifications, once per app
Notifications go through one store per app. Install it where you create the app, and render one
`` at the root, before the page content:
```ts
// main.ts
import { createI9kToaster } from '@9klabs/design';
app.use(createI9kToaster({ labels: { region: 'Notifications', dismiss: 'Dismiss' } }));
```
```ts
// plugins/toaster.ts in Nuxt
import { createI9kToaster } from '@9klabs/design';
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.use(createI9kToaster());
});
```
The store is created per app, never as a module singleton, so two server-rendered requests never
share notifications. [Notifications and dialogs](https://design.the9klabs.com/guides/notifications-and-dialogs.md) covers the rest.
## Server rendering
Every component renders on the server. Anything that needs the browser waits until the component
is mounted: `matchMedia`, timers, the dialog API, and Nino's maze seed when none is given. In
practice:
- **No hydration surprises from viewport checks.** `I9kSidebarLayout` renders its wide layout on
the server and switches to the drawer once mounted on a narrow screen. `I9kNavigation` lays out
with CSS alone.
- **Teleports go to ``.** `I9kNavMenu` teleports its panel there, which Nuxt and VitePress
place correctly. A hand-rolled server renderer must put body teleports at the very start of
``.
- **The theme class is yours.** The components follow the `dark` or `light` class on ``.
Set it before the first paint so a dark page never flashes light. See
[Theming and dark mode](https://design.the9klabs.com/foundations/theming.md).
# Working with AI agents
> Point Claude or any coding agent at these docs. Every page is Markdown at its URL plus .md, llms.txt indexes them, and each component has a self-contained prompt.
## Every page is Markdown
Add `.md` to any page's URL to get the same content as Markdown, with nothing to scrape:
| Page | Markdown |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------ |
| [`/components/button`](https://design.the9klabs.com/components/button.md) | /components/button.md |
| [`/foundations/theming`](https://design.the9klabs.com/foundations/theming.md) | /foundations/theming.md |
A component's Markdown holds everything its page shows: the import line, the code of every
example, the gotchas, the full props, emits and slots tables, the types they reference, and the
agent prompt. Each HTML page also points to its twin with
``.
The **Copy page as Markdown** button at the top of each page puts that text on your clipboard, ready
to paste into a chat.
## The index and the full text
| URL | What it holds |
| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| /llms.txt | The index, in the [llms.txt](https://llmstxt.org) format: the rules every component follows, then a link and a one-line summary for every page's Markdown. Start here. |
| /llms-full.txt | Every page's Markdown in one file, for agents that would rather read everything at once. |
| /components.json | Every component's API as JSON: props with resolved types and defaults, emits with payloads, slots, gotchas and prompts. |
## Prompts
Two kinds of prompt come with the docs:
- **Using a component.** The end of every component page has an agent prompt. It names the
import, every prop with its type and default, the events, the slots, the behavior that
surprises people, and a usage example. Paste it when you ask an agent to build with that
component.
- **Building a new component.** [Building a component](https://design.the9klabs.com/guides/building-a-component.md) ends with a
prompt that teaches an agent this repository's conventions: scoped styles, the size scale,
logical properties, the registry entry and the tests.
## Set up Claude Code in a consuming app
To have Claude Code use the design system in an app, add a short note to the app's `CLAUDE.md`
(or `AGENTS.md`), so every session knows where to look:
```md
## UI components
Build UI with @9klabs/design. Before using a component, read its Markdown page,
for example https://design.the9klabs.com/components/button.md. The index of every page
is https://design.the9klabs.com/llms.txt. Follow the rules listed there.
```
With that note, a request like "add a sign-in form" goes from the index to the
[`I9kField`](https://design.the9klabs.com/components/field.md), [`I9kInput`](https://design.the9klabs.com/components/input.md) and
[`I9kButton`](https://design.the9klabs.com/components/button.md) pages, and from there to code that uses their real props.
## Rules every component follows
The constraints a props table cannot express are on one page:
[Rules for agents](https://design.the9klabs.com/guides/rules-for-agents.md). They are also at the top of `llms.txt`.
# Design tokens
> The colors, spacing, radii, control sizes and page measures every component is built from, read straight from tokens.css.
## How to use them
Read tokens with `var()` in your own styles, and prefer the role over the raw value. A surface
should use `--surface-color`, not `--white-color`, so it turns dark with the theme:
```css
.my-card {
padding: var(--spacing-8);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
background: var(--surface-color);
color: var(--theme-text-color);
}
```
To change a value for the whole app, redefine it after the stylesheet. Your CSS sits outside the
library's cascade layers, so it wins:
```css
:root {
--radius-md: 0.5rem;
}
```
## Color roles
Green marks primary actions and selection. Orange is a brand accent for details such as link
underlines and featured borders. Errors, warnings and successes each have their own foreground and
background, tuned per theme.
| Role | Tokens | Use for |
| -------------- | --------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
| Canvas | `--theme-bg-color` | The page background |
| Surface | `--surface-color` | Panels, cards and fields |
| Raised surface | `--surface-raised-color` | Featured panels and banners, with `--shadow-sm` |
| Interaction | `--surface-hover-color`, `--surface-sunken-color` | Neutral hover and pressed states |
| Selection | `--selected-bg-color`, `--primary-text-color` | Selected filters, page numbers, radio cards |
| Feedback | `--error-color`, `--error-bg-color`, `--success-color`, `--success-bg-color`, `--warning-color`, `--warning-bg-color` | Validation and status messages |
| Boundaries | `--control-border-color`, `--focus-color`, `--border-color` | Control borders, keyboard focus, and subtle dividers |
Glass tokens (`--glass-bg`, `--glass-blur`) are for overlays such as the navigation bar. Content
surfaces stay opaque, so their contrast holds over images and decorative backgrounds.
## Every token
### Brand colors
| Token | HSL | Hex |
| --- | --- | --- |
| `--primary-color` | hsl(143 64% 24%) | #166434 |
| `--primary-color-alpha-12` | hsl(143 64% 24% / 0.12) | #1664341F |
| `--on-primary-color` | hsl(0 0% 100%) | #FFFFFF |
| `--accent-color` | hsl(23 98% 46%) | #E85A02 |
| `--on-accent-color` | hsl(0 0% 0%) | #000000 |
| `--dark-color` | hsl(214 45% 7%) | #0A111A |
| `--dark-color-alpha-05` | hsl(214 45% 7% / 0.05) | #0A111A0D |
| `--dark-color-alpha-10` | hsl(214 45% 7% / 0.1) | #0A111A1A |
| `--dark-color-alpha-20` | hsl(214 45% 7% / 0.2) | #0A111A33 |
| `--dark-color-lighten-30` | hsl(214 45% 30%) | #2A486F |
| `--gray-color` | hsl(0 0% 80%) | #CCCCCC |
| `--dark-gray-color` | hsl(0 0% 46%) | #757575 |
| `--white-color` | hsl(0 0% 100%) | #FFFFFF |
| `--white-color-alpha-05` | hsl(0 0% 100% / 0.05) | #FFFFFF0D |
| `--white-color-alpha-15` | hsl(0 0% 100% / 0.15) | #FFFFFF26 |
| `--white-color-alpha-20` | hsl(0 0% 100% / 0.2) | #FFFFFF33 |
| `--white-color-darken-30` | hsl(0 0% 70%) | #B3B3B3 |
### Theme colors
| Token | Light | Dark |
| --- | --- | --- |
| `--theme-bg-color` | hsl(0 0% 98%) (#FAFAFA) | hsl(0 0% 6%) (#0F0F0F) |
| `--theme-text-color` | hsl(0 0% 10%) (#1A1A1A) | hsl(0 0% 96%) (#F5F5F5) |
| `--text-color` | hsl(0 0% 10%) (#1A1A1A) | hsl(0 0% 96%) (#F5F5F5) |
| `--primary-text-color` | hsl(143 64% 24%) (#166434) | hsl(143 58% 44%) (#2FB161) |
| `--text-color-light` | hsl(0 0% 40%) (#666666) | hsl(0 0% 60%) (#999999) |
| `--code-bg` | hsl(0 0% 90%) (#E6E6E6) | hsl(0 0% 10%) (#1A1A1A) |
| `--border-color` | hsl(0 0% 84%) (#D6D6D6) | hsl(0 0% 25%) (#404040) |
| `--surface-color` | hsl(0 0% 100%) (#FFFFFF) | hsl(0 0% 10%) (#1A1A1A) |
| `--surface-raised-color` | hsl(0 0% 100%) (#FFFFFF) | hsl(0 0% 14%) (#242424) |
| `--surface-sunken-color` | hsl(0 0% 96%) (#F5F5F5) | hsl(0 0% 8%) (#141414) |
| `--surface-hover-color` | hsl(0 0% 94%) (#F0F0F0) | hsl(0 0% 18%) (#2E2E2E) |
| `--control-border-color` | hsl(0 0% 46%) (#757575) | hsl(0 0% 55%) (#8C8C8C) |
| `--focus-color` | hsl(143 64% 24%) (#166434) | hsl(143 58% 60%) (#5ED48B) |
| `--primary-hover-color` | hsl(143 64% 20%) (#12542B) | hsl(143 64% 28%) (#1A753D) |
| `--primary-pressed-color` | hsl(143 64% 16%) (#0F4323) | hsl(143 64% 20%) (#12542B) |
| `--selected-bg-color` | hsl(143 35% 94%) (#EAF5EE) | hsl(143 24% 16%) (#1F3327) |
| `--selected-hover-bg-color` | hsl(143 35% 90%) (#DDEEE3) | hsl(143 24% 13%) (#19291F) |
| `--selected-pressed-bg-color` | hsl(143 35% 86%) (#CFE8D8) | hsl(143 24% 10%) (#132018) |
| `--success-color` | hsl(143 64% 24%) (#166434) | hsl(143 58% 65%) (#72DA9A) |
| `--success-bg-color` | hsl(143 35% 94%) (#EAF5EE) | hsl(143 24% 13%) (#19291F) |
| `--error-color` | hsl(0 65% 38%) (#A02222) | hsl(0 80% 76%) (#F39191) |
| `--error-bg-color` | hsl(0 70% 97%) (#FDF2F2) | hsl(0 30% 13%) (#2B1717) |
| `--warning-color` | hsl(35 90% 28%) (#885207) | hsl(40 85% 70%) (#F4C871) |
| `--warning-bg-color` | hsl(42 90% 94%) (#FDF5E2) | hsl(35 35% 13%) (#2D2316) |
| `--glass-bg` | hsl(0 0% 100% / 0.2) (#FFFFFF33) | hsl(0 0% 0% / 0.2) (#00000033) |
| `--glass-shadow` | hsl(0 0% 0% / 0.1) (#0000001A) | hsl(0 0% 0% / 0.1) (#0000001A) |
# Sizes
> One sm, md and lg scale for every control, so buttons, inputs and badges of the same size line up.
## The scale
```vue
SmallMediumLarge
```
Each size sets three things, from these tokens:
| Size | Height | Font size | Gap |
| ---- | ------------------------------ | ----------------------------------- | -------------------- |
| `sm` | `--control-height-sm` (2rem) | `--control-font-size-sm` (0.875rem) | `--component-gap-sm` |
| `md` | `--control-height-md` (2.5rem) | `--control-font-size-md` (1rem) | `--component-gap-md` |
| `lg` | `--control-height-lg` (3rem) | `--control-font-size-lg` (1.125rem) | `--component-gap-lg` |
## Mixing controls
Because heights come from the same tokens, controls of one size sit on one line:
```vue
SearchBeta
```
::: warning I9kInput uses `uiSize`
`I9kInput` names its scale `uiSize` (`ui-size` in templates), not `size`. The native HTML `size`
attribute, a width in characters, stays available on the input.
:::
## Inside a field
`I9kField` passes its size down to the control it wraps, so the label, hint and control match:
```vue
```
## Typing a size
The scale is one type, `I9kComponentSize`, exported from the package. Use it for your own props
rather than writing the union again:
```ts
import type { I9kComponentSize } from '@9klabs/design';
defineProps<{ size?: I9kComponentSize }>();
```
The package also exports `I9kTone` (`neutral`, `primary`, `success`, `warning`, `danger`) for
components that will take a status color. No component reads it yet.
# Theming and dark mode
> Switch between light and dark with one class on the html element, remember the reader's choice, and make your own components follow.
## Set the class
```html
```
With no class, the page is light. The stylesheet also sets `color-scheme`, so native scrollbars and
form controls match.
## Let the reader choose
[`I9kThemeSwitcher`](https://design.the9klabs.com/components/theme-switcher.md) is the control. It holds no state and never
touches the document itself: it shows the value you give it and emits the flipped one. Apply the
class and remember the choice yourself:
```vue
```
```vue
```
## Avoid a flash of the wrong theme
The server cannot know the reader's choice. Read it in a small inline script in `
`, so the
class is set before the first paint:
```html
```
Then start the switch at `false` on the server and read the real state once mounted, as above, so
hydration matches.
## Make your own components follow
Use the theme tokens rather than fixed colors, and your component turns dark with everything else:
```css
.my-panel {
background: var(--surface-color);
color: var(--theme-text-color);
border: 1px solid var(--border-color);
}
```
When a value has no token, reach the dark theme from scoped CSS with `:global`:
```css
.my-panel__glow {
opacity: 0.4;
}
:global(.dark) .my-panel__glow {
opacity: 0.2;
}
```
The [tokens page](https://design.the9klabs.com/foundations/tokens.md#every-token) lists each theme token with its light and dark
value.
## Inside the green section
[`I9kSection`](https://design.the9klabs.com/components/section.md) with `variant="primary"` fills itself with the brand green and
re-maps the theme tokens for its contents: `--primary-color` becomes white, `--on-primary-color`
green, and so on. Components inside it stay legible without props of their own.
```vue
Applications close on Friday.Apply now
```
::: warning What to keep out of the green
Status colors (error, warning, success) are not re-mapped and disappear on the green. An
`I9kModal` placed inside the section inherits the re-map and turns translucent. Render both outside
it.
:::
# Arabic and right-to-left
> Mark Arabic content with lang and dir. The components mirror on their own and take the Arabic typefaces from the language.
## Mark the language and the direction
Set both on the element that holds the content, usually ``:
```html
```
They do different jobs, so set both:
- **`lang="ar"`** selects the typography. `:lang(ar)` switches body text to Thmanyah Sans and
headings to Thmanyah Serif Display.
- **`dir="rtl"`** mirrors the layout. Components are built with logical properties
(`margin-inline-start`, `inset-inline-end`), so start and end follow the reading direction
without a prop.
```vue
ابدأ الآناعرف المزيدجديد
```
Every demo on this site can be flipped to right-to-left with the **文A** button in the top bar, so
you can check any component in both directions.
## Mixed content
A page in one language often quotes the other. Mark the quoted part on its own element:
```html
The course is called الذكاء الاصطناعي.
```
Some components take the language of a part of their content as a prop, because they render that
part themselves. A toast's `detail`, for example, accepts `detailLang` and `detailDir` for a server
message in Arabic on an English page.
## When the language is unknown
If the markup cannot carry `lang`, for example text from a CMS without language metadata, apply
the typography by class instead:
| Class | Applies |
| --------------------- | ------------------------------------------------------------------- |
| `.i9k-arabic` | Thmanyah Sans, the Arabic body family |
| `.i9k-arabic-display` | Thmanyah Serif Display, the Arabic heading family |
| `.i9k-root` | IBM Plex Sans, to set the English family on an app shell explicitly |
## Directions that do not mirror
Some things keep their direction in both languages: code stays left to right, inside an Arabic page
as on this site. Where a component offers a direction-aware option, it is named logically rather
than left and right. `I9kNino`'s `look` takes `start` and `end`, so he glances toward the same side
of a form in both languages, and `I9kGlow`'s `position` takes `top-start` and `top-end`.
## Checking your work
Any visual change needs checking in both directions, as well as in light and dark. A layout that
uses `left`, `right`, `margin-left` or `text-align: left` will not mirror. Use the logical
equivalents (`inset-inline-start`, `margin-inline-start`, `text-align: start`).
# Page layout
> One page column shared by the navigation, every section and the footer, plus the grid, cluster and panel that arrange content inside it.
## The page column
[`I9kContainer`](https://design.the9klabs.com/components/container.md) is the column. It sets only a width and centres it, so
padding, backgrounds and minimum heights stay yours.
| Size | Token | Width | Use for |
| ---- | ---------------------- | ----- | ------------------------------- |
| `sm` | `--container-width-sm` | 42rem | A reading column for long text |
| `md` | `--container-width-md` | 75rem | The site column, the default |
| `lg` | `--container-width-lg` | 90rem | A wide, desktop-first work area |
On a viewport narrower than the column, it keeps a gutter on each side: `--container-gutter`
(2rem), or `--container-gutter-sm` (1rem) at 768px and below. `I9kNavigation` and `I9kFooter` render
their content in a container of their own, so anything you put in one lines up with the brand in the
bar and the columns in the footer.
```vue
…
```
### Three rules
1. **A full-bleed background goes around the container, not on it.** The `` carries the
fill, and the container inside holds the content.
2. **Never nest one container in another.** The inner one would take the gutter off a second time,
so a phone loses twice the margin. A narrower block inside a column is a plain element with
`max-width` and `margin-inline: auto`.
3. **Widen an area, not every container.** For an admin area that needs the wide column throughout,
chrome included, redefine `--container-width-md: var(--container-width-lg)` on the area's root
instead of passing `size="lg"` to each container.
## The page wrapper
[`I9kPageContainer`](https://design.the9klabs.com/components/page-container.md) is the page-level wrapper: a flex column with a
minimum height, deriving its width from the same token. Its `size` is the gutter, so its content
lines up with the chrome for `md`, and for `sm` and `lg` once the viewport is wide enough. Anything
that must share the bar's edge on every viewport belongs in an `I9kContainer`.
## Arranging content
Layout components style only their own layout. They never change their children.
[`I9kGrid`](https://design.the9klabs.com/components/grid.md) lays out equal columns, collapsing on narrow screens:
```vue
OneTwoThree
```
[`I9kCluster`](https://design.the9klabs.com/components/cluster.md) spaces a row of items that wraps, such as actions or tags:
```vue
VueTypeScriptAccessibility
```
[`I9kPanel`](https://design.the9klabs.com/components/panel.md) is the bordered surface for grouped content, and
[`I9kSection`](https://design.the9klabs.com/components/section.md) gives a block of the page its heading, actions and, when
asked, a collapsible body.
## Semantic roots
Surface and layout components accept `as` when the element needs stronger meaning, for example an
`I9kPanel` that is an ``, or an `I9kContainer` that is the page's ``:
```vue
……
```
## A frame of its own
For a page that is its own frame, such as a course player or this documentation, use
[`I9kSidebarLayout`](https://design.the9klabs.com/components/sidebar-layout.md). It renders the bar, a sticky sidebar that becomes
a drawer on narrow screens, and the `` landmark itself, so do not wrap it in another
``.
# Notifications and dialogs
> Raise toasts from one per-app toaster, interrupt with a modal, and let the two work together without lost or repeated announcements.
## Pick the right component
| You want to… | Use |
| ------------------------------------------------------------------- | ---------------------------------------------------- |
| Report the result of an action: saved, sent, failed | A toast, through [`I9kToaster`](https://design.the9klabs.com/components/toaster.md) |
| Show standing status in the page, such as "This course is archived" | [`I9kToast`](https://design.the9klabs.com/components/toast.md), in the page flow |
| Interrupt for a focused task, such as an edit form | [`I9kModal`](https://design.the9klabs.com/components/modal.md) |
| Explain a control on hover or focus | [`I9kTooltip`](https://design.the9klabs.com/components/tooltip.md) |
| Announce something site-wide above the navigation | [`I9kAnnouncementBar`](https://design.the9klabs.com/components/announcement-bar.md) |
Never show the same message both ways. A toast replaces an inline banner for an action's result.
## Set up the toaster
Install one store per app and render one toaster at the root, **before** the page content in the
tree. A toaster never announces a toast raised before its own setup ran, so one rendered below the
page would show, but not announce, a toast the page raised while setting up.
```ts
// main.ts
import { createI9kToaster } from '@9klabs/design';
app.use(createI9kToaster({ labels: { region: 'Notifications', dismiss: 'Dismiss' } }));
```
```vue
```
Translate through the store, not through props. Its `labels` object is reactive; assign its fields
when the locale changes:
```ts
const toaster = useI9kToaster();
Object.assign(toaster.labels, { region: t('notifications.region'), dismiss: t('dismiss') });
```
## Raise a toast
**From state you already have.** `useI9kToastSource` follows a getter: the toast shows while it
returns options, updates when they change, and goes away when it returns `null` or the component
unmounts.
```ts
const error = ref(null);
useI9kToastSource(() =>
error.value ? { id: 'login', variant: 'error', message: error.value } : null,
);
async function signIn() {
error.value = null; // clear first, so the same failure twice shows twice
try {
await login();
} catch (failure) {
error.value = 'Those details did not match.';
}
}
```
**Imperatively.** `useI9kToaster()` returns the store:
```ts
const toaster = useI9kToaster();
const id = toaster.show({ variant: 'success', message: 'Changes saved.' });
toaster.dismiss(id);
```
A toast is plain text. `info` and `success` dismiss themselves after five seconds. `warning` and
`error` stay until dismissed, since a reader may need time to act on them. Showing a toast with an
`id` already on screen replaces it rather than stacking a duplicate.
## Open a modal
The parent owns `open`. The close button and Escape only ask to close, by emitting
`update:open(false)`. The modal closes when you set `open` to `false`, and focus returns to
whatever opened it.
```vue
Edit moduleCancelSave
```
::: tip Three behaviors to rely on
- Clicking the backdrop never closes a modal, so a stray click cannot lose an unsaved form.
- The body and footer render only while open, so a form inside starts fresh every time. Put
`autofocus` on its first field.
- Set `dismissible` to `false` while a save is in flight, so it cannot be closed halfway.
:::
## Toasts and modals together
An open `I9kModal` renders a toaster of its own inside the dialog, one layer above the page's. Only
the deepest one shows toasts, so a toast raised while the modal is open is readable, announced and
dismissable above the inert page. `I9kSidebarLayout`'s drawer does the same on narrow screens.
There is nothing to wire up.
Raise the result of a modal's task in the same tick you close it, in either order:
```ts
async function save() {
saving.value = true;
await updateModule(title.value);
saving.value = false;
open.value = false;
toaster.show({ variant: 'success', message: 'Module saved.' });
}
```
The page's toaster shows that toast and announces it once the dialog has closed. A toast the modal
already announced is never announced again by the page.
::: warning Keep modals out of the green section
An `I9kModal` placed inside an `I9kSection variant="primary"` inherits its re-mapped colors and turns
translucent. Render the modal outside the section.
:::
# Building a component
> The conventions a new component in @9klabs/design follows, from scoped styles and the size scale to its registry entry and tests, with a prompt that teaches them to an agent.
## The files a component touches
| File | What goes in it |
| ------------------------------- | ----------------------------------------------------------------------------------------- |
| `src/components/I9kThing.vue` | The component, with `