Every one of the 59 components in @9klabs/design, with props read from source, live demos, and a copy-paste prompt per component. Machine-readable at /components.json and /llms.txt.
Install
npm install @9klabs/design
Import the stylesheet once, at your application entry:
import '@9klabs/design/style.css';
In a Vue Router app, pass link-component="RouterLink" to I9kButton so its link form renders a router link.
Tokens
Color
Every color custom property declared in src/styles/tokens.css, read from that file at build time. Each swatch paints its literal value, so alpha tokens show against the checkerboard behind them.
Components declare local custom properties on their root class and redefine them per size modifier, drawing from this shared scale rather than from raw brand values.
--control-height-sm
--control-height-md
--control-height-lg
--control-font-size-sm
--control-font-size-md
--control-font-size-lg
--component-gap-sm
--component-gap-md
--component-gap-lg
Page column
The one measure the site chrome and every page section share. I9kContainer reads it, and so do I9kNavigation, I9kFooter and I9kPageContainer; the gutter is what each side keeps on a viewport narrower than the column.
--container-width-sm
--container-width-md
--container-width-lg
--container-gutter
--container-gutter-sm
Layout & surfaces
I9kContainer
The page column: a centred, width-capped wrapper that sets only width and centring, so a section, a main or a bar of chrome all share one measure. I9kNavigation, I9kFooter and I9kPageContainer already sit in it.
Default
The site column. The header's brand and the footer's columns start on this same line.
<I9kContainer style="outline: 1px dashed var(--border-color)">
<I9kText variant="lede">The site column. The header's brand and the footer's columns start on this same line.</I9kText>
</I9kContainer>
The band fills the page; only its content keeps to the column.
<section style="padding-block: var(--spacing-13); background: var(--surface-sunken-color)">
<I9kContainer as="div" style="outline: 1px dashed var(--border-color)">
<I9kText>The band fills the page; only its content keeps to the column.</I9kText>
</I9kContainer>
</section>
Watch out
It sets only width and centring. Padding, backgrounds, flex and min-height are the caller's; a full-bleed band is an element around the container.
Do not nest containers: the inner one takes the gutter off a second time on narrow viewports. A narrower block inside a column is `max-width` + `margin-inline: auto`.
`size` picks the column (`--container-width-sm|md|lg`), not the gutter. To widen a whole area including its header and footer, redefine `--container-width-md` on the area's root.
I9kNavigation and I9kFooter already render their content in one; do not wrap them in another container.
Props
Prop
Type
Default
as
string | Component
'div'
size
'sm' | 'md' | 'lg'
'md'
Slots: default
Agent prompt
Use I9kContainer from @9klabs/design to put a page section, a <main> or any full-width band's content in the shared page column, so it lines up with the site header and footer.
import { I9kContainer } from '@9klabs/design';
Props:
- as?: string | Component (default 'div') — the rendered root tag or component, e.g. 'section' or 'main'.
- size?: 'sm' | 'md' | 'lg' (default 'md') — which column: 'md' is the site column (--container-width-md, 75rem), 'sm' a reading column for long text (42rem), 'lg' a wide, desktop-first work area (90rem).
Emits: none.
Slots: default — the content.
Behavior: the root is exactly the column wide once the viewport allows it, and centred. On a narrower viewport it keeps a gutter on each side (--container-gutter, 2rem; 1rem at 768px and below). The gutter is a margin, not padding, so the column's edge is where content starts and a child with width: 100% fills exactly the column. It sets nothing else: no padding, background, flex or min-height.
Alignment: I9kNavigation's bar and I9kFooter's content render inside an I9kContainer of their own, and I9kPageContainer derives its width from the same token, so anything you put in an I9kContainer lines up with the brand in the header and the columns in the footer. To give a whole area the wide column, chrome included (an admin area, say), redefine --container-width-md: var(--container-width-lg) on that area's root element instead of passing size="lg" to each container.
IMPORTANT: a background or a full-bleed band goes on the element around the container, not on it: a <section> with the fill wraps the I9kContainer that holds the content.
IMPORTANT: do not nest one I9kContainer inside another. The inner one measures its 100% from the outer column and takes the gutter off again, so on a phone the content loses the gutter twice. A narrower block inside a column is a plain element with max-width and margin-inline: auto.
Usage:
<section class="pricing">
<I9kContainer>
<I9kSectionHeading title="Pricing" />
<I9kGrid :columns="3">…</I9kGrid>
</I9kContainer>
</section>
<I9kContainer as="main" size="sm">
<I9kText v-for="paragraph in terms" :key="paragraph">{{ paragraph }}</I9kText>
</I9kContainer>
I9kGrid
CSS grid layout wrapper with a fixed 1/2/3-column or auto-filling track and a size-driven gap. Collapses to one column on narrow viewports.
`size` sets the gap only — it has no effect on column count or track width, so pick columns via `columns`.
`columns="auto"` fills as many 280px-minimum tracks as fit; it is not the same as a fixed column count.
Every multi-column value (2, 3, auto) collapses to a single column at viewports under 768px.
Props
Prop
Type
Default
as
string | Component
'div'
columns
1 | 2 | 3 | 'auto'
1
size
'sm' | 'md' | 'lg'
'md'
Slots: default
Agent prompt
Use I9kGrid from @9klabs/design to lay out a set of cards or panels in a responsive grid.
import { I9kGrid } from '@9klabs/design';
Props:
- as?: string | Component (default 'div') — the rendered root tag or component.
- columns?: 1 | 2 | 3 | 'auto' (default 1)
- size?: 'sm' | 'md' | 'lg' (default 'md') — sets the gap between tracks only; it does not affect column count or track width.
Emits: none.
Slots: default — the grid items. Each direct child occupies one cell; wrap items yourself (e.g. in I9kPanel) if they need their own padding or surface.
Column behavior: `columns={1|2|3}` renders that many equal-width tracks (`repeat(n, minmax(0, 1fr))`). `columns="auto"` renders `repeat(auto-fill, minmax(280px, 1fr))` — as many equal tracks as fit at a 280px minimum, wrapping to new rows as the container narrows, with no JS breakpoint logic involved. Below a 768px viewport, every multi-column value (2, 3, and 'auto') collapses to a single column; `columns={1}` is already one column and is unaffected.
IMPORTANT: `size` only changes the gap (sm/md/lg spacing tokens) — pick the column count with `columns`, not `size`.
Usage:
<I9kGrid :columns="3" size="md"><I9kPanel>One</I9kPanel><I9kPanel>Two</I9kPanel><I9kPanel>Three</I9kPanel></I9kGrid>
I9kPageContainer
Centered, width-capped page wrapper with a size-driven horizontal gutter and a minimum height, in the shared page column. Use it once per page as the outermost content wrapper; for a section of a page use I9kContainer.
Default
A centered page container in the shared page column, with responsive gutters.
Page content
<I9kPageContainer style="min-height: 12rem; outline: 1px dashed var(--border-color)">
<I9kText variant="lede">A centered page container in the shared page column, with responsive gutters.</I9kText>
<I9kPanel size="sm">Page content</I9kPanel>
</I9kPageContainer>
`size` only sets the horizontal gutter (padding-inline) — the column width is the shared `--container-width-md` regardless of size.
Below 768px viewports the gutter always drops to the `sm` spacing, overriding whatever `size` was passed.
The container does not space its children vertically; group related content yourself.
It is a page wrapper: a flex column with a minimum height. For a section, or anything that must not set those, use I9kContainer, which shares the column.
Props
Prop
Type
Default
as
string | Component
'div'
size
'sm' | 'md' | 'lg'
'md'
Slots: default
Agent prompt
Use I9kPageContainer from @9klabs/design as the outermost wrapper for a page's content.
import { I9kPageContainer } from '@9klabs/design';
Props:
- as?: string | Component (default 'div') — the rendered root tag or component.
- size?: 'sm' | 'md' | 'lg' (default 'md') — sets the inline gutter (padding-inline) only.
Emits: none.
Slots: default — the page content.
Behavior: renders a flex column whose content is the shared page column wide (--container-width-md, 75rem, the same measure I9kContainer, I9kNavigation and I9kFooter use), with the gutter outside that column, centered with `margin-inline: auto`, and a `min-height: calc(100vh - 250px)` so short pages still fill the viewport. Below a 768px viewport the width becomes 100% and the gutter is forced to the 'sm' spacing regardless of the `size` prop.
IMPORTANT: `size` only changes the horizontal gutter — it does not change the column width or add vertical spacing between children. Wrap groups of children yourself (e.g. in I9kCluster or a styled div) if they need gaps. Only 'md' keeps the content aligned with the header and footer on a viewport narrower than the column; 'sm' and 'lg' change the gutter and so the alignment there.
IMPORTANT: for one section of a page, or a wrapper that must not impose a minimum height or a flex column, use I9kContainer instead.
Usage:
<I9kPageContainer size="md"><I9kText variant="lede">Welcome</I9kText><I9kPanel size="sm">Page content</I9kPanel></I9kPageContainer>
I9kCluster
Flex-wrap row that keeps items center-aligned and evenly gapped, wrapping onto new lines instead of overflowing. Use it for groups of buttons, badges, or other inline controls.
`size` sets the gap only — there is no column or breakpoint prop, wrapping is automatic flex-wrap.
Items are center-aligned on the cross axis, not baseline-aligned.
Props
Prop
Type
Default
as
string | Component
'div'
size
'sm' | 'md' | 'lg'
'md'
Slots: default
Agent prompt
Use I9kCluster from @9klabs/design to lay out a horizontal group of items that should wrap instead of overflow.
import { I9kCluster } from '@9klabs/design';
Props:
- as?: string | Component (default 'div') — the rendered root tag or component.
- size?: 'sm' | 'md' | 'lg' (default 'md') — sets the gap between items only.
Emits: none.
Slots: default — the items to cluster.
Behavior: renders `display: flex; flex-wrap: wrap; align-items: center;` with a size-driven gap. Items wrap onto new rows as the container narrows; there is no column count or breakpoint logic to configure.
IMPORTANT: items are vertically centered (`align-items: center`), not baseline-aligned — mixed-height items (e.g. a button next to a badge) line up on their centers, not their text baselines.
Usage:
<I9kCluster size="md"><I9kButton>Primary action</I9kButton><I9kButton variant="link">Secondary action</I9kButton><I9kBadge variant="outline">Status</I9kBadge></I9kCluster>
I9kPanel
Opaque, bordered surface for grouping content. Use it as the standard card/surface wrapper wherever content needs visual separation from the page background.
Surface and feedback in context
Join the next workshop
Get the schedule and joining instructions by email.
Enter a valid email address.
<I9kPanel>
<I9kSectionHeading title="Join the next workshop" description="Get the schedule and joining instructions by email." />
<I9kInput v-model="email" label="Email address" type="email" error="Enter a valid email address." />
<I9kCluster>
<I9kButton variant="primary">Join the workshop</I9kButton>
<I9kButton>Cancel</I9kButton>
</I9kCluster>
</I9kPanel>
`variant="flat"` removes the border, background, and backdrop-filter, keeping only padding and radius.
`size` controls padding and, on the `default`/`flat` variants, border radius — it does not control width, since the panel is only as wide as its container allows.
`variant="feature"` always renders the large border radius, overriding whatever `size` would otherwise set — pick `size` on a feature panel for padding only, not radius.
Props
Prop
Type
Default
as
string | Component
'div'
size
'sm' | 'md' | 'lg'
'md'
variant
'default' | 'feature' | 'flat'
'default'
Slots: default
Agent prompt
Use I9kPanel from @9klabs/design to wrap content in a bordered surface.
import { I9kPanel } from '@9klabs/design';
Props:
- as?: string | Component (default 'div') — the rendered root tag or component.
- size?: 'sm' | 'md' | 'lg' (default 'md') — sets padding, and border radius on the 'default' and 'flat' variants.
- variant?: 'default' | 'feature' | 'flat' (default 'default') — sets the border and background treatment.
Emits: none.
Slots: default — the panel content.
Behavior: 'default' renders a 1px border and an opaque surface. 'feature' uses a raised surface, subtle shadow, and orange-accented border for content that should stand out (e.g. a highlighted pricing tier), and always renders with the large border radius, regardless of `size`. 'flat' removes the border, background, and backdrop-filter entirely, leaving only the size-driven padding — useful when you want the padding/radius rhythm without a visible surface, e.g. nested inside another panel.
IMPORTANT: `variant="flat"` strips the border and background — do not combine it with content that depends on the panel having a visible surface.
IMPORTANT: `variant="feature"` hardcodes the large border radius and ignores `size` for radius — `size` on a feature panel changes padding only.
Usage:
<I9kPanel variant="feature" size="lg"><I9kText variant="lede">Highlighted content</I9kText></I9kPanel>
Content
I9kText
Text primitive for body copy and intros. Use it for any paragraph-level content that should follow the design system type scale rather than reaching for a bare <p>.
Variants
Body text keeps normal content flow.
Lede text introduces a page with a deliberate measure and quieter color.
<I9kText variant="body">Body text keeps normal content flow.</I9kText>
<I9kText variant="lede">Lede text introduces a page with a deliberate measure and quieter color.</I9kText>
Sizes
Small body text
Medium body text
Large body text
<I9kText size="sm">Small body text</I9kText>
<I9kText size="md">Medium body text</I9kText>
<I9kText size="lg">Large body text</I9kText>
Watch out
`variant="lede"` caps the measure at 62ch and adds bottom margin — meant for intro paragraphs under a heading, not short or already-constrained copy.
Props
Prop
Type
Default
as
string | Component
'p'
size
'sm' | 'md' | 'lg'
'md'
variant
'body' | 'lede'
'body'
Slots: default
Agent prompt
Use I9kText from @9klabs/design for paragraph-level copy.
import { I9kText } from '@9klabs/design';
Props:
- as?: string | Component (default 'p') — the rendered root tag or component.
- size?: 'sm' | 'md' | 'lg' (default 'md')
- variant?: 'body' | 'lede' (default 'body')
Emits: none.
Slots: default — the text content.
Behavior: 'lede' sets a wider line height, a quieter color, and caps the measure at 62ch, for the introductory paragraph under a heading. 'body' is normal flowing copy with no measure cap.
IMPORTANT: 'lede' adds bottom margin and a max-width of 62ch — do not use it for short inline copy or content already inside a constrained container, or the extra spacing will look wrong.
Usage:
<I9kText variant="lede" size="lg">A practical text primitive for branded content.</I9kText>
I9kSectionHeading
Heading with an optional description for introducing a section of a page. Use it above any grouped block of content — a card grid, a list, a feature set.
Default
Speaking
Practical sessions grounded in building, shipping, and leading with AI.
<I9kSectionHeading title="Speaking" description="Practical sessions grounded in building, shipping, and leading with AI." />
title and description are plain string props, not slots — there is no way to pass rich markup into the heading or description.
`level` defaults to 2 and is not inferred from context; set it to match the surrounding document outline.
Props
Prop
Type
Default
title*
string
—
description
string | null
null
id
string | null
null
level
2 | 3 | 4 | 5 | 6
2
Agent prompt
Use I9kSectionHeading from @9klabs/design to introduce a page section.
import { I9kSectionHeading } from '@9klabs/design';
Props:
- title: string (required)
- description?: string | null (default null)
- id?: string | null (default null) — set on the rendered heading element, e.g. for an in-page anchor link.
- level?: 2 | 3 | 4 | 5 | 6 (default 2) — the rendered heading level (renders <h{level}>).
Emits: none.
Slots: none — title and description are text-only props, not slots.
IMPORTANT: pick `level` to match the surrounding document outline; the component does not infer nesting from context, and the default is h2.
Usage:
<I9kSectionHeading title="Speaking" description="Practical sessions grounded in building, shipping, and leading with AI." />
I9kPageHeader
Large hero-style heading for the top of a page, with optional subtitle, description, actions, and avatar. Use it once per page, at the top.
Default
Practical AI from someone who ships.
Practical talks for builders and technology teams, grounded in real product work.
<I9kPageHeader title="Practical AI from someone who ships." description="Practical talks for builders and technology teams, grounded in real product work." />
With avatar and actions
Abdelrahman Ismail
Software engineer sharing how AI is changing the way software gets built.
<I9kPageHeader title="Abdelrahman Ismail" description="Software engineer sharing how AI is changing the way software gets built.">
<template #avatar>
<img alt="" src="https://avatars.githubusercontent.com/u/20756985?s=160&v=4" width="112" height="112" style="border-radius: 50%" />
</template>
<template #actions>
<div class="cluster"><I9kButton variant="primary">Invite Ismail</I9kButton></div>
</template>
</I9kPageHeader>
Watch out
Reserve I9kPageHeader for the single main heading at the top of a page — use I9kSectionHeading for headings inside the page body.
The side-by-side avatar layout only activates when the `avatar` slot is filled; otherwise the header always stacks in one column.
Props
Prop
Type
Default
title*
string
—
description
string | null
null
id
string | null
null
level
1 | 2 | 3 | 4 | 5 | 6
1
Slots: subtitleactionsavatar
Agent prompt
Use I9kPageHeader from @9klabs/design for the hero heading at the top of a page.
import { I9kPageHeader } from '@9klabs/design';
Props:
- title: string (required)
- description?: string | null (default null)
- id?: string | null (default null) — set on the rendered heading element.
- level?: 1 | 2 | 3 | 4 | 5 | 6 (default 1) — the rendered heading level (renders <h{level}>).
Emits: none.
Slots:
- subtitle — rendered directly under the title, above the description.
- actions — rendered after the description, e.g. for buttons.
- avatar — when present, switches the header to a side-by-side layout with the avatar next to the title/description/actions block, stacking to centered-column on narrow viewports.
Behavior: the layout only changes shape when the avatar slot is used — with no avatar slot, everything renders as a single stacked column.
IMPORTANT: use I9kPageHeader once per page, at the top — it renders a display-scale title (clamp up to 4rem) meant for the page's main heading, not for section headings (use I9kSectionHeading for those).
Usage:
<I9kPageHeader title="Practical AI from someone who ships." description="Practical talks for builders and technology teams, grounded in real product work."><template #actions><I9kButton variant="primary">Book a session</I9kButton></template></I9kPageHeader>
I9kArticleHeader
Wide banner image for the top of an article, with a generated watermark fallback when there is no image. Use it once, at the top of an article body.
With image
<I9kArticleHeader title="Are AI coding tools ready to replace programmers?" image-src="https://i.ytimg.com/vi/NfRC9Lj4-rU/hqdefault.jpg" eager />
Fallback watermark
#9k
<I9kArticleHeader title="Shipping with agents" watermark="9k" />
Watch out
`title` only supplies the fallback alt text for the image — it is never rendered as visible text, so pair this with a real heading elsewhere on the page.
With no `imageSrc`, the component renders a decorative gradient watermark, not a loading or empty state — it never fetches anything.
Set `eager` only for an above-the-fold header; the default is lazy loading with normal fetch priority.
Props
Prop
Type
Default
title*
string
—
imageSrc
string | null
null
imageAlt
string
''
watermark
string
'9k'
eager
boolean
false
Agent prompt
Use I9kArticleHeader from @9klabs/design at the top of an article for a banner image or a branded fallback.
import { I9kArticleHeader } from '@9klabs/design';
Props:
- title: string (required) — used to build the image's alt text when `imageAlt` is not given; not rendered as visible text.
- imageSrc?: string | null (default null)
- imageAlt?: string (default '')
- watermark?: string (default '9k') — short text shown in the gradient fallback when there is no imageSrc.
- eager?: boolean (default false) — when true, loads the image eagerly with high fetchpriority instead of lazily; use only for an above-the-fold hero image.
Emits: none.
Slots: none.
Behavior: with `imageSrc` set, renders a 2:1 <figure><img></figure> capped at 420px tall, object-fit cover. With no `imageSrc`, renders an aria-hidden gradient panel showing `#{{ watermark }}` instead — a decorative placeholder, not a loading state.
IMPORTANT: `title` is not rendered as visible text anywhere — it only feeds the image's default alt text, so still write a real, separate heading (e.g. I9kPageHeader) for the article's visible title.
Usage:
<I9kArticleHeader title="Are AI coding tools ready to replace programmers?" image-src="https://i.ytimg.com/vi/NfRC9Lj4-rU/hqdefault.jpg" eager />
I9kBadge
Small inline label for status, category, or tag content. Use it next to a heading or inside a card to mark a short piece of metadata.
Badge text is uppercased by CSS automatically — pass normal-case text, do not pre-uppercase it.
`variant="tag"` adds a decorative leading "#" automatically; do not include one in the slot content.
Props
Prop
Type
Default
as
string | Component
'span'
size
'sm' | 'md' | 'lg'
'md'
variant
'solid' | 'outline' | 'tag'
'outline'
Slots: default
Agent prompt
Use I9kBadge from @9klabs/design for a short inline label.
import { I9kBadge } from '@9klabs/design';
Props:
- as?: string | Component (default 'span') — the rendered root tag or component.
- size?: 'sm' | 'md' | 'lg' (default 'md')
- variant?: 'solid' | 'outline' | 'tag' (default 'outline')
Emits: none.
Slots: default — the badge text.
Behavior: 'solid' fills with the primary color for a featured/callout badge. 'outline' is a bordered, transparent badge for general metadata (the default). 'tag' renders a subtler filled chip with a leading "#" decoration, for topic/category tags.
IMPORTANT: badge text renders uppercase via CSS (text-transform) regardless of the casing you pass — write it in normal case in the slot content, do not pre-uppercase it yourself.
Usage:
<I9kBadge variant="tag" size="sm">AI</I9kBadge>
I9kStat
Value/label/source stack for a single statistic. Use it in a grid of a few key numbers, such as metrics on an about page or a pricing comparison.
Each of value/label/source only renders when its prop is set or its same-named slot is filled — an I9kStat with none of the three renders an empty shell.
When both the prop and the matching slot are given, the slot content is what renders.
Props
Prop
Type
Default
as
string | Component
'div'
label
string
undefined
size
'sm' | 'md' | 'lg'
'md'
source
string
undefined
value
string | number
undefined
Slots: valuelabelsource
Agent prompt
Use I9kStat from @9klabs/design to display one statistic (value, label, and optional source).
import { I9kStat } from '@9klabs/design';
Props:
- as?: string | Component (default 'div') — the rendered root tag or component.
- label?: string (default undefined)
- size?: 'sm' | 'md' | 'lg' (default 'md')
- source?: string (default undefined)
- value?: string | number (default undefined)
Emits: none.
Slots:
- value — overrides the `value` prop's rendering, e.g. to bold or link the number.
- label — overrides the `label` prop's rendering.
- source — overrides the `source` prop's rendering, e.g. to link a citation.
Behavior: each of value/label/source renders only when its prop is set OR its matching slot is filled — pass either the prop or the slot for a given piece, not neither.
IMPORTANT: nothing renders for value, label, or source unless you supply the prop or the matching slot — do not rely on a slot alone without checking the prop is left unset (they are not mutually exclusive, but the slot always takes rendering priority when both are present).
Usage:
<I9kStat label="monthly npm downloads" value="480k+" source="npm snapshot" size="lg" />
I9kLevelMeter
A short row of rising steps with some filled, for a level out of a few, such as a course level. It is a named meter, the level can be shown in words beside it, and a description can explain it in a tooltip.
`label` is the accessible name and is never shown; the visible words come from `valueText`.
An empty `label` logs a development warning, because a meter needs a name.
The steps follow the reading direction, so they rise from right to left on an Arabic page.
With a `description`, the outermost element is the tooltip wrapper: a caller's class lands there, and the meter inside it takes the focus. Inside a card whose link is stretched over it, lift the meter above the overlay (`position: relative; z-index: 1`) so hover and tap reach it.
Props
Prop
Type
Default
value*
number
—
label*
string
—
max
number
5
valueText
string
undefined
description
string
undefined
size
'sm' | 'md' | 'lg'
'md'
Agent prompt
Use I9kLevelMeter from @9klabs/design to show a level out of a small maximum, such as a course's level (3 of 5).
import { I9kLevelMeter } from '@9klabs/design';
Props:
- value: number (required) — how many steps are filled; rounded and clamped to 0..max.
- label: string (required) — the meter's accessible name, e.g. "Level". Not shown on screen.
- max?: number (default 5) — how many steps are drawn; a value under 1 draws one.
- valueText?: string (default undefined) — the level in words, e.g. "Junior". Shown beside the steps and announced as the meter's value.
- description?: string (default undefined) — who or what the level is for, e.g. "Up to 2 years in a real team". Shown in an I9kTooltip on hover, keyboard focus or tap, and set as the meter's accessible description; the meter becomes focusable so a keyboard can open it.
- size?: 'sm' | 'md' | 'lg' (default 'md')
Emits: none.
Slots: none.
Behavior: the root is role="meter" with aria-valuemin 0, aria-valuemax max and aria-valuenow value; valueText becomes aria-valuetext. The steps themselves are aria-hidden. A filled step is solid and an empty one is outlined, and the steps rise in height, so the level does not rest on colour.
IMPORTANT: pass valueText whenever the level has a name. Without it, nothing on screen says the level in words, and a screen reader announces only the number.
Usage:
<I9kLevelMeter :value="3" label="Level" value-text="Junior" description="Up to 2 years in a real team." />
I9kSticker
A die-cut sticker with a holographic foil that follows the pointer, for a badge a person has earned. The foil is drawn over the sticker image and clipped to its shape; it stands still for people who prefer reduced motion.
The image needs a transparent background: the foil is clipped to its alpha, so an opaque square image gets a square foil.
Dark areas stay dark under the foil and light areas take its colours, so draw the art as dark lines on a light fill.
There is no href prop. Wrap the sticker in a link and let its alt name that link.
No device-tilt effect: it would need a motion-permission prompt on iOS.
Props
Prop
Type
Default
src*
string
—
alt*
string
—
size
'sm' | 'md' | 'lg'
'md'
Agent prompt
Use I9kSticker from @9klabs/design to show an earned badge as a holographic sticker.
import { I9kSticker } from '@9klabs/design';
Props:
- src: string (required) — the sticker art: dark line art on a light fill, a white die-cut border, and a transparent background (PNG, WebP or SVG). The foil takes the light areas and leaves the dark lines dark.
- alt: string (required) — the sticker's accessible name.
- size?: 'sm' | 'md' | 'lg' (default 'md') — 4rem, 6rem or 8rem square.
Emits: none.
Slots: none.
Behavior: renders an inline SVG with role="img" named by alt. A rainbow gradient is masked by the image's own alpha and multiplied over it. Moving a pointer over the sticker slides the foil (a custom property set from script, never a style attribute, so it is safe under style-src 'self'); leaving eases it back. A focused link around the sticker plays one sweep. Under prefers-reduced-motion the foil stands still at a fixed angle.
IMPORTANT: to make a sticker a link, wrap it in your own <a> or router link. The sticker's alt then names the link, so write alt as the link's text ("View the certificate for Git").
Usage:
<I9kSticker src="/stickers/early-adopter.webp" alt="Early adopter" size="lg" />
I9kLinkCard
Clickable card linking out to an external resource, with a name, description, and optional image/badge/arrow. Use it for a grid of projects, articles, or external links.
The entire card renders as one <a> element opened in a new tab — never nest another link or button inside it.
`click` fires alongside normal navigation, not instead of it; it does not stop the link from opening.
`showImage` only hides the image slot area — it does not remove the badge/arrow header or change card size.
Props
Prop
Type
Default
name*
string
—
url*
string
—
description*
string
—
image
string | null
null
badge
string | null
null
showImage
boolean
true
arrow
boolean
false
arrowLabel
string
'↗'
size
'sm' | 'md' | 'lg'
'md'
Emits
Event
Payload
click
[event: MouseEvent]
Agent prompt
Use I9kLinkCard from @9klabs/design for a clickable card that links to an external URL.
import { I9kLinkCard } from '@9klabs/design';
Props:
- name: string (required)
- url: string (required) — the card is an <a href="url"> opened in a new tab (target="_blank", rel="noopener").
- description: string (required)
- image?: string | null (default null)
- badge?: string | null (default null)
- showImage?: boolean (default true) — set false to hide the image even when `image` is given.
- arrow?: boolean (default false) — shows a trailing arrow glyph in the top-right corner.
- arrowLabel?: string (default '↗') — the arrow glyph itself.
- size?: 'sm' | 'md' | 'lg' (default 'md')
Emits: click with the native MouseEvent — the link still navigates; use this only for side effects like analytics, not to prevent navigation.
Slots: none — name, description, image, and badge are all props.
IMPORTANT: the whole card is a single <a> to `url` opened in a new tab; do not nest another interactive element (button, link) inside it.
Usage:
<I9kLinkCard name="vue3-carousel" url="https://github.com/ismail9k/vue3-carousel" description="A flexible, responsive carousel component for Vue 3." badge="Open source" arrow />
I9kTimelineCard
Dated entry in a vertical timeline/rail, with a title, body, and optional thumbnail. Use it for a chronological list of talks, posts, or events.
A practical discussion of what today's tools can do and what still needs engineering judgement.
<I9kTimelineCard date="2026-01-25" linked>
<template #title><a href="#">Are AI coding tools ready to replace programmers?</a></template>
<p>A practical discussion of what today's tools can do and what still needs engineering judgement.</p>
<template #thumbnail>
<img src="https://i.ytimg.com/vi/NfRC9Lj4-rU/hqdefault.jpg" alt="" width="160" height="100" />
</template>
</I9kTimelineCard>
`linked` only adds hover styling and a full-card click target over an `<a>` inside the `title` slot — you must put that `<a>` there yourself.
Dates are formatted in UTC, so a plain date string like "2026-01-25" always shows as that calendar day regardless of the viewer's timezone.
The `thumbnail` slot is hidden entirely below a 600px viewport — do not rely on it for content that has no alternative in `default`.
Props
Prop
Type
Default
date*
string | Date
—
linked
boolean
false
locale
string
'en'
size
'sm' | 'md' | 'lg'
'md'
Slots: titledefaultthumbnail
Agent prompt
Use I9kTimelineCard from @9klabs/design for one entry in a vertical, dated timeline. Stack multiple instances to build the full timeline — the rail connects visually between adjacent cards via CSS, with no wrapping list component required.
import { I9kTimelineCard } from '@9klabs/design';
Props:
- date: string | Date (required) — an ISO date string ('2026-01-25') or a Date object; formatted with Intl.DateTimeFormat as a long date (e.g. "January 25, 2026") in UTC, so a date-only string never shifts to the previous/next day from timezone drift.
- linked?: boolean (default false) — when true, styles the whole card as hoverable/clickable and stretches the title's <a> to fill the card (via a CSS ::after overlay); requires the title slot to contain a real <a>.
- locale?: string (default 'en') — passed to Intl.DateTimeFormat, e.g. 'ar' for Arabic date formatting.
- size?: 'sm' | 'md' | 'lg' (default 'md')
Emits: none.
Slots:
- title — the entry heading; wrap it in an <a> when using `linked`.
- default — the entry body content.
- thumbnail — an image shown beside the body (hidden under 600px viewport width).
IMPORTANT: `linked` only changes hover styling and stretches an <a> found inside the `title` slot to cover the card — put a real <a> in `title` yourself, the component does not create one for you.
Usage:
<I9kTimelineCard date="2026-01-25" linked><template #title><a href="#">Are AI coding tools ready to replace programmers?</a></template><p>A practical discussion of what today's tools can do and what still needs engineering judgement.</p></I9kTimelineCard>
I9kProfileCard
Card pairing an avatar with a name, bio, and optional action links. Use it for an author byline, a team member card, or a speaker bio.
With actions
<I9kProfileCard name="Abdelrahman Ismail" alias="Ismail9k" name-prefix="Written by" avatar-src="https://avatars.githubusercontent.com/u/20756985?s=120&v=4">
Software engineer sharing how AI is changing the way software gets built.
<template #actions>
<a href="#instagram">Instagram</a>
<a href="#github">GitHub</a>
</template>
</I9kProfileCard>
The avatar column renders only when `avatarSrc` is set or the `avatar` slot is filled — there is no empty placeholder avatar.
`namePrefix` and `alias` are plain text rendered inline with `name` — they cannot hold markup or links.
Props
Prop
Type
Default
name*
string
—
alias
string | null
null
namePrefix
string | null
null
avatarSrc
string | null
null
avatarAlt
string
''
size
'sm' | 'md' | 'lg'
'md'
Slots: avatardefaultactions
Agent prompt
Use I9kProfileCard from @9klabs/design for a person's avatar, name, and bio.
import { I9kProfileCard } from '@9klabs/design';
Props:
- name: string (required)
- alias?: string | null (default null) — rendered after the name, separated by " · " (e.g. a handle).
- namePrefix?: string | null (default null) — rendered before the name (e.g. "Written by").
- avatarSrc?: string | null (default null)
- avatarAlt?: string (default '')
- size?: 'sm' | 'md' | 'lg' (default 'md')
Emits: none.
Slots:
- avatar — overrides the built-in <img> rendered from `avatarSrc`; use this for a custom avatar element.
- default — the bio content, rendered under the name.
- actions — a row of action links/buttons under the bio.
Behavior: the avatar column only renders at all when either the `avatar` slot is filled or `avatarSrc` is set — with neither, the card is name/bio/actions only, no empty avatar space.
IMPORTANT: `namePrefix` and `alias` are plain strings rendered inline around `name` in one paragraph — they cannot contain markup or links; put any linked text in the `default` or `actions` slot instead.
Usage:
<I9kProfileCard name="Abdelrahman Ismail" alias="Ismail9k" name-prefix="Written by" avatar-src="https://avatars.githubusercontent.com/u/20756985?s=120&v=4">Software engineer sharing how AI is changing the way software gets built.<template #actions><a href="#instagram">Instagram</a><a href="#github">GitHub</a></template></I9kProfileCard>
I9kAvatar
A round picture of a person or account from any image URL, with a fallback slot (such as initials) for when there is no picture.
A `data:` URI `src` (such as a generated avatar) only loads when the consuming site's Content-Security-Policy allows `img-src data:`.
The fallback slot is ordinary text, so screen readers read it out unless the caller sets `aria-hidden="true"` on the avatar — do that when the name is already given beside it.
Props
Prop
Type
Default
src
string | null
null
alt
string
''
size
'sm' | 'md' | 'lg'
'md'
Slots: default
Agent prompt
Use I9kAvatar from @9klabs/design for a round picture of a person or account.
import { I9kAvatar } from '@9klabs/design';
Props:
- src?: string | null (default null) — any image URL, including a data: URI. When it is null or empty, the default slot renders instead of the picture.
- alt?: string (default '') — the picture's alternative text.
- size?: 'sm' | 'md' | 'lg' (default 'md') — the diameter follows the shared control scale, --control-height-sm|md|lg, so an avatar sits level with buttons of the same size.
Emits: none.
Slots:
- default — fallback content (for example initials) rendered when there is no `src`.
Accessibility: the picture is decorative by default (alt=''), because an avatar usually sits beside the person's name or inside a button that already has a label. Pass `alt` when the avatar stands alone and nothing else names the person.
The root is a <span class="i9k-avatar i9k-avatar--<size>">; attributes such as aria-hidden fall through to it.
Usage:
<I9kAvatar :src="user.image" size="sm" />
<I9kAvatar alt="Ismail">IK</I9kAvatar>
I9kBandanaAvatar
A large round photo of a person with a bandana that draws itself across the eyes, once when the images load and again when a pointer enters. The bandana never shows for people who prefer reduced motion.
Sizes (move a pointer over one to draw the bandana again)
The bandana position is tuned to one photo. Another photo needs its own --i9k-bandana-avatar-bandana-* values, set where the eyes are.
The bandana is placed with `left`, on purpose: the photo does not mirror in a right-to-left page, so the bandana must not either.
The component does not resize or convert images. Give it files already sized for the display; a multi-megabyte photo ships as it is.
There is no bandana for people who prefer reduced motion, and none before the images load, so never rely on it to carry meaning.
Props
Prop
Type
Default
src*
string
—
alt*
string
—
bandanaSrc*
string
—
size
'sm' | 'md' | 'lg'
'md'
loading
'eager' | 'lazy'
'eager'
Agent prompt
Use I9kBandanaAvatar from @9klabs/design for the large round photo of the site's author, with the bandana that draws itself over the eyes.
import { I9kBandanaAvatar } from '@9klabs/design';
Props:
- src: string (required) — the photo. Each site hosts its own file; use a square image about twice the displayed size.
- alt: string (required) — the photo's alternative text. Pass '' when the person is already named beside the avatar.
- bandanaSrc: string (required) — the bandana: a wide image with a transparent background.
- size?: 'sm' | 'md' | 'lg' (default 'md') — 8rem, 11rem or 14rem across.
- loading?: 'eager' | 'lazy' (default 'eager') — pass 'lazy' when the avatar is below the fold. It applies to both images.
Emits: none.
Slots: none.
Behavior: the photo is cropped to a circle with a primary-colour border and a soft ring. The bandana is decorative (alt="", aria-hidden). It draws itself from left to right, holds, and fades: once, when both images have loaded (also when they had loaded before the component hydrated), and again each time a pointer enters the avatar. Under prefers-reduced-motion it stays hidden and nothing moves.
Sizing and placement are CSS custom properties on the root, which a caller may set from its own stylesheet:
- --i9k-bandana-avatar-size — the diameter (the size prop sets it).
- --i9k-bandana-avatar-bandana-top, -left, -width (percentages of the photo) and -tilt (an angle) — where the bandana sits. The defaults (38%, 35%, 44%, -2deg) fit the photo the component was drawn for.
It renders no style attribute and injects no styles, so it works under a Content-Security-Policy of style-src 'self'.
Usage:
<I9kBandanaAvatar src="/img/avatar.webp" bandana-src="/img/avatar-bandana.webp" alt="Abdelrahman Ismail" />
I9kCollapsible
Native, independently expanding disclosure surface with slots for rich summary and body content.
Closed
Course module
Module lessons and resources.
<I9kCollapsible>
<template #summary>Course module</template>
<p>Module lessons and resources.</p>
</I9kCollapsible>
Initially open
Course module
This body is visible when the example first renders.
<I9kCollapsible :default-open="true">
<template #summary>Course module</template>
<p>This body is visible when the example first renders.</p>
</I9kCollapsible>
`defaultOpen` controls only the initial state; bind `v-model:open` for a controlled disclosure such as expand-all.
A one-way `:open` binding does not learn about user toggles; use `v-model:open` so the parent value stays in step.
Uncontrolled sibling instances expand independently; control them with `v-model:open` if only one section may be open.
Provide visible summary content because the summary slot is the disclosure control label.
Use `variant="flush"` for disclosures listed inside a panel or sidebar; the default card is meant to stand on the page.
Props
Prop
Type
Default
defaultOpen
boolean
false
open
boolean
undefined
variant
'card' | 'flush'
'card'
Emits
Event
Payload
toggle
[open: boolean]
update:open
[open: boolean]
Slots: summarydefault
Agent prompt
Use I9kCollapsible from @9klabs/design for a single branded disclosure that may contain rich Vue markup.
import { I9kCollapsible } from '@9klabs/design';
Props:
- defaultOpen?: boolean (default false) — sets only the initial native open state when `open` is not bound.
- open?: boolean (default undefined) — bind with v-model:open to control the open state from the parent, e.g. an expand-all / collapse-all button. Leave it unbound for the browser-managed, uncontrolled behavior.
- variant?: 'card' | 'flush' (default 'card') — `flush` drops the border and background, for a list of disclosures inside a panel or a sidebar.
Emits:
- toggle(open: boolean) — reports the current native details.open value after every native toggle, including one caused by changing a bound `open`.
- update:open(open: boolean) — emitted only when `open` is bound and a user toggle makes the native state differ from it.
Slots:
- summary — content rendered inside the native <summary>.
- default — content rendered in the disclosure body.
Behavior: the component renders native <details>/<summary>. Unless `open` is bound, each instance owns its browser-managed state, so siblings expand independently and any number may stay open. When `open` is bound, changing it opens or closes the disclosure, and a user toggle is reported through update:open.
IMPORTANT: `defaultOpen` is only an initial-state option; bind `open` (v-model:open) when the parent must set the state, such as expand-all. A bound `open` without a listener for update:open does not follow user toggles back into the parent — use v-model:open, not a one-way :open.
Usage:
<I9kCollapsible :default-open="true" @toggle="(open) => console.log(open)">
<template #summary><strong>Module 01</strong> · 7 topics</template>
<ol><li>What is coding?</li><li>Engineering judgment</li></ol>
</I9kCollapsible>
I9kSection
Titled page section, named by its heading, whose header carries its own actions and an optional toolbar. It can collapse behind a disclosure button inside the heading, controlled with v-model:open or left to manage itself. The primary variant fills it with the brand green, and fullWidth lets that fill reach the viewport edges as a band.
<I9kSection variant="primary" title="Join the next workshop">
<template #actions>
<I9kButton size="sm" variant="primary">Reserve a seat</I9kButton>
</template>
<div style="display: grid; gap: var(--spacing-8)">
<I9kText>Two evenings of live pairing on real Vue codebases, with notes to keep.</I9kText>
<I9kCluster>
<I9kButton>See the schedule</I9kButton>
<I9kButton variant="link" href="#">Read past notes</I9kButton>
</I9kCluster>
</div>
</I9kSection>
Full-width primary band
Ready to ship your first course?
The band reaches the edges; the text keeps its column.
Panels turn translucent on the green.
Borders and text follow.
<div style="padding-inline: var(--spacing-13)">
<I9kSection variant="primary" full-width title="Ready to ship your first course?">
<div style="display: grid; gap: var(--spacing-8)">
<I9kText variant="lede">The band reaches the edges; the text keeps its column.</I9kText>
<I9kGrid :columns="2">
<I9kPanel size="sm">Panels turn translucent on the green.</I9kPanel>
<I9kPanel size="sm">Borders and text follow.</I9kPanel>
</I9kGrid>
<I9kCluster>
<I9kButton variant="primary">Start building</I9kButton>
<I9kButton>Talk to us</I9kButton>
</I9kCluster>
</div>
</I9kSection>
</div>
Binding `open` makes the section collapsible; use `collapsible` alone for a section that manages its own state.
A controlled section only emits `update:open`; bind `v-model:open` or the disclosure button does nothing.
`defaultOpen` is true, unlike I9kCollapsible, because a section shows its content unless asked not to.
Actions and toolbar stay visible while the section is closed, so a result an action reports inside the body is hidden until the section opens; open it from the action.
Every instance is a named `<section>`, which is a region landmark; do not use it for small groups.
The body is hidden with the `hidden` attribute, not an inline style, so it stays closed under a `style-src` Content Security Policy.
`fullWidth` only extends the fill, so it has no visible effect on `variant="default"`.
Render I9kModal outside a `variant="primary"` section, not next to its trigger in `#actions` or the body: its native `<dialog>` inherits the white-on-green tokens while it paints off the green, so its surface turns translucent and the page shows through.
The full-width band paints past the section with a border-image outset. An ancestor with `overflow: hidden`, `clip` or `auto` cuts it off at that ancestor edge, and it paints over the background of a non-positioned side column beside the page column.
On `variant="primary"`, a primary I9kButton inverts to white with green text, and focus rings turn white; do not override those colors back to green.
Error, warning and success colors are not re-mapped on `variant="primary"`: in the light theme the error color is 1.06:1 on the green and the success color is the green itself, so an error message or invalid border is invisible. Keep any field that can show an error, and any feedback, off the green.
On the default and full-width variants the disclosure button sits 0.5rem past the section inline-start edge so its title lines up with the body, and its focus ring reaches about 0.8rem out; inside a scroll container or `overflow: hidden`, leave that much room or both are clipped. The contained primary variant keeps both inside its padding.
Props
Prop
Type
Default
title*
string
—
id
string
undefined
level
2 | 3 | 4 | 5 | 6
2
collapsible
boolean
false
open
boolean
undefined
defaultOpen
boolean
true
variant
'default' | 'primary'
'default'
fullWidth
boolean
false
Emits
Event
Payload
update:open
[open: boolean]
Slots: actionstoolbardefault
Agent prompt
Use I9kSection from @9klabs/design for a titled section of a page whose actions belong in its header, such as "Invite member" beside "Team", optionally collapsible.
import { I9kSection } from '@9klabs/design';
Props:
- title: string (required) — the heading text; it is also the disclosure button's label when collapsible.
- id?: string — set on the <section>; the heading gets "<id>-heading" and the body "<id>-body". Omit it and unique ids are generated.
- level?: 2 | 3 | 4 | 5 | 6 (default 2) — the rendered heading level (<h{level}>).
- collapsible?: boolean (default false) — puts a disclosure button in the heading and lets the section manage its own open state.
- open?: boolean (default undefined) — bind with v-model:open to control the open state from the parent, e.g. for expand-all. Binding a boolean makes the section collapsible; also set `collapsible` when the bound value can be undefined, or the section has no disclosure button while it is.
- defaultOpen?: boolean (default true) — the initial state of a collapsible section whose `open` is not bound.
- variant?: 'default' | 'primary' (default 'default') — 'primary' fills the section with the brand green, pads it, rounds its corners, and re-themes its content for the green (see Behavior).
- fullWidth?: boolean (default false) — on the primary variant, the green reaches the viewport edges while the content stays in its column, as a full-width band. It has no visible effect on the default variant, which has no fill.
Emits:
- update:open(open: boolean) — every click on the disclosure button, with the requested state.
Slots:
- default — the section body; it gets the `hidden` attribute while closed.
- actions — controls for the section, rendered in the header after the heading, outside the disclosure button, and still visible while closed.
- toolbar — a full-width header row under the heading and actions, such as filters or expand-all; still visible while closed.
Data attributes (stable hooks for tests and page scripts):
- data-i9k-section-toggle — on the disclosure button (only while collapsible).
- data-i9k-section-body — on the body wrapper.
Behavior: the root is a <section> labelled by its heading (aria-labelledby), so it is a named region landmark. While collapsible, the heading wraps a <button> with aria-expanded and aria-controls pointing at the body. A controlled section only emits update:open on click and changes when the parent changes `open`. Without `open` and `collapsible` the heading is plain text and the body always shows.
On variant 'primary', the section re-maps the theme tokens its header and body read: text, links and focus rings turn white; a primary I9kButton inverts to white with green text; borders, I9kPanel surfaces and secondary buttons turn translucent white. Nested design-system components need no extra props on the green. The green is the same in light and dark themes.
With fullWidth, place the section where the page column is (inside the page container, or anywhere with the column's width). The section box stays that wide and only its green fill paints out to the viewport edges, so the page never scrolls sideways.
IMPORTANT: bind `v-model:open`, not a one-way `:open`, or clicking the button never opens or closes the section.
IMPORTANT: on variant 'primary', keep content to text, buttons, links, badges and panels. Error, warning and success colors are not re-mapped, so an error message or invalid border is invisible on the green: keep any field that can show an error, and toasts, off it. Render I9kModal outside the section too, or its dialog inherits the white-on-green tokens and turns translucent.
IMPORTANT: use it for the major sections of a page. Every instance is a named landmark, so do not use it for small groups inside a form or a card.
Usage:
<I9kSection id="team" v-model:open="teamOpen" title="Team">
<template #actions>
<I9kButton size="sm" @click="invite"><I9kIcon name="add" /> Invite member</I9kButton>
</template>
<I9kPanel size="sm">Member list</I9kPanel>
</I9kSection>
<I9kSection variant="primary" full-width title="Join the next cohort">
<template #actions>
<I9kButton variant="primary">Enroll now</I9kButton>
</template>
<I9kText>Six weeks, live sessions, and a project you ship.</I9kText>
</I9kSection>
I9kFaqList
List of collapsible question/answer pairs composed from I9kCollapsible. Use it for plain-text FAQ content without wiring open/close state.
Default
Is this library tree-shakeable?
Yes — each component is a separate export, so unused ones are dropped at build time.
Does it support right-to-left layouts?
Yes, every component is checked in both LTR and RTL, and logical CSS properties are used throughout.
Can I use it outside a Vue Router app?
Yes — components that render links accept a plain href by default and only need a router component when you pass one explicitly.
<I9kFaqList :items="[
{ question: 'Is this library tree-shakeable?', answer: 'Yes — each component is a separate export, so unused ones are dropped at build time.' },
{ question: 'Does it support right-to-left layouts?', answer: 'Yes, every component is checked in both LTR and RTL, and logical CSS properties are used throughout.' },
{ question: 'Can I use it outside a Vue Router app?', answer: 'Yes — components that render links accept a plain href by default and only need a router component when you pass one explicitly.' },
]" />
Watch out
Each item's `question` string is used as its list key — keep questions unique within a single `items` array.
question and answer are plain text only; there is no slot for markup or links inside an item.
Items expand independently via native <details> — opening one never closes another.
Props
Prop
Type
Default
items*
I9kFaqItem[]
—
Agent prompt
Use I9kFaqList from @9klabs/design to render a list of collapsible FAQ entries.
import { I9kFaqList } from '@9klabs/design';
// Item shape: { question: string; answer: string }
// This type (I9kFaqItem) is not exported from the package; inline the object shape or declare
// your own local type.
Props:
- items: I9kFaqItem[] (required) — each item renders as a native <details>/<summary> pair; each item's `question` is used as its Vue :key, so keep questions unique within one list.
Emits: none.
Slots: none — question and answer are plain text per item, not slots.
Behavior: each item is rendered through I9kCollapsible and retains independent native <details> state. Opening one item never closes another, and no Vue state wiring is required.
IMPORTANT: both `question` and `answer` render as plain text — there is no way to pass markup or links into an item; keep answers to plain sentences.
Usage:
<I9kFaqList :items="[{ question: 'Is this library tree-shakeable?', answer: 'Yes — each component is a separate export, so unused ones are dropped at build time.' }]" />
I9kGithubEmbed
Compact card linking to a GitHub repository by "owner/repo" name. Use it to reference a specific repo inline in content, e.g. a blog post or project list.
This is a static link, not a live embed — it never fetches stars, description, or any other data from GitHub.
Pass the full "owner/repo" string; the component does not validate or prepend an owner, so a bare repo name produces a broken link.
Props
Prop
Type
Default
repo*
string
—
Agent prompt
Use I9kGithubEmbed from @9klabs/design to link to a GitHub repository.
import { I9kGithubEmbed } from '@9klabs/design';
Props:
- repo: string (required) — an "owner/repo" string, e.g. 'ismail9k/vue3-carousel'. The component builds the link as `https://github.com/${repo}` and does not validate the format.
Emits: none.
Slots: none.
Behavior: this is a static link card — it makes no network request and fetches no repository data (stars, description, etc.) from GitHub; it only renders the GitHub icon and the `repo` text as a link.
IMPORTANT: pass the full "owner/repo" string, not just the repo name — the component does not prepend an owner, so 'vue3-carousel' alone produces a broken link (github.com/vue3-carousel) instead of 'ismail9k/vue3-carousel'.
Usage:
<I9kGithubEmbed repo="ismail9k/vue3-carousel" />
I9kIcon
SVG icon rendered from the library's built-in icon set by name. Use it anywhere a small inline glyph is needed — social links, nav items, buttons.
Icon names are limited to the fixed set in src/icons/paths.json — add a new icon there, do not inline raw SVG in a component.
I9kIcon is aria-hidden by default; pass `title` or `desc` whenever the icon is the only content of an interactive element.
Props
Prop
Type
Default
name*
I9kIconName
—
title
string
''
desc
string
''
size
string | number
'1.2em'
Agent prompt
Use I9kIcon from @9klabs/design to render a built-in SVG icon by name.
import { I9kIcon } from '@9klabs/design';
Props:
- name: I9kIconName (required) — one of the names in src/icons/paths.json: 'facebook', 'twitter', 'medium', 'linkedin', 'behance', 'github', 'menu', 'mail', 'dev', 'phone', 'landMark', 'home', 'instagram', 'youtube', 'tiktok', 'x', '9klabs', 'linktree', 'sun', 'moon', 'translate', 'add', 'edit', 'delete', 'close', 'arrowUp', 'arrowDown', 'archive', 'unarchive', 'eye', 'eyeOff', 'warning', 'bars'.
- title?: string (default '') — an accessible name for the icon; setting this (or `desc`) makes the icon exposed to assistive tech as role="img" instead of hidden.
- desc?: string (default '') — a longer accessible description; same effect as `title` on hiddenness.
- size?: string | number (default '1.2em') — sets both width and height, e.g. '24px', '2em', 32.
Emits: none.
Slots: none.
Behavior: icon names come from src/icons/paths.json, a fixed lookup table of path data — I9kIcon cannot render an arbitrary SVG path or a name outside that set. The component is aria-hidden="true" by default (a decorative icon); it only gets role="img" and becomes visible to assistive tech when you pass a `title` or `desc`.
IMPORTANT: to add a new icon, add its entry to src/icons/paths.json (as a path string, or { viewBox, path } for a non-24x24 icon) — never inline a raw <svg> in a component in place of I9kIcon.
IMPORTANT: I9kIcon is aria-hidden unless you pass `title` or `desc` — always set one of those when the icon is the only content of a link or button (e.g. an icon-only social link), or it will be invisible to screen readers.
Usage:
<I9kIcon name="github" title="GitHub" size="1.5em" />
I9kAsciiEmoji
Small text-based emoticon (e.g. "^_^") rendered in a monospace face, for a lighter-touch alternative to emoji or icon glyphs.
TypeScript does not enforce a closed set for `name`: the label map is typed as `Record<string, string>`, so `keyof typeof labels` widens to plain `string` and any value compiles.
Passing a `name` outside the seven known strings without also passing `label` renders `role="img"` with no `aria-label` at all — always pass `label` explicitly for anything but the seven known strings.
Props
Prop
Type
Default
name*
keyof typeof labels
—
label
string | null
null
size
'sm' | 'md' | 'lg'
'md'
color
'primary' | 'accent' | 'muted' | 'current'
'primary'
Agent prompt
Use I9kAsciiEmoji from @9klabs/design for a small ASCII-art emoticon.
import { I9kAsciiEmoji } from '@9klabs/design';
Props:
- name: keyof typeof labels (required) — the internal label map is typed as `Record<string, string>`, so this declared type widens to plain `string` at compile time; TypeScript accepts any string here, not just the seven below. The seven strings with a built-in label are '^_^', '·ᴗ·', '◡̈', '>‿<', 'x_x', 'o_o', '-_-'.
- label?: string | null (default null) — overrides the automatic aria-label; when omitted, a matching label is used for the seven known strings ('^_^' → "happy", '·ᴗ·' → "gentle smile", '◡̈' → "smiling", '>‿<' → "joyful", 'x_x' → "exhausted", 'o_o' → "surprised", '-_-' → "unimpressed").
- size?: 'sm' | 'md' | 'lg' (default 'md')
- color?: 'primary' | 'accent' | 'muted' | 'current' (default 'primary')
Emits: none.
Slots: none — the emoticon text comes only from `name`.
Behavior: always renders role="img". An aria-label is present when `label` is passed, or when `name` is one of the seven known strings; otherwise no aria-label is rendered at all.
IMPORTANT: pass one of the seven known strings, or pass `label` explicitly. TypeScript does not restrict `name` to a closed set — any string compiles — but an unrecognized `name` has no entry in the internal label map, so the element ends up with role="img" and no aria-label, breaking the accessibility contract this component exists to provide.
Usage:
<I9kAsciiEmoji name="^_^" size="lg" color="accent" />
I9kNino
Nino, the 9k pixel mascot: an inline-SVG character with eight expressions (one of them hide and seek, one dizzy), stepped ambient life, a reaction beat on mood changes, one-shot actions (wave, jump, nod, shake, dizzy), a talking state, a logical gaze direction, and motion that switches itself off for reduced-motion visitors.
Nested inside a host <svg>, pass size="auto" — otherwise the component's CSS inline-size and block-size override the x/y/width/height attributes you are positioning him with.
Without a `label` he is aria-hidden, so a screen reader never announces him. That is the intended default for decoration; pass the localized name when he is content.
`animated: false` and prefers-reduced-motion are two independent switches. Turning the prop off does not make the media query redundant, and the media query does not make the prop redundant.
`look` moves only the eyes, and start/end are reading-direction, not left/right — they swap on an Arabic page.
Actions are imperative: hold a template ref and call play(). There is no action prop, so replaying the same action is just calling play() again.
A changed expression shows 100ms later, after his eyes blink shut. Read the prop, not the rendered class, when you test what mood you asked for.
While hiding, `look` has no effect (his eyes stay behind his hands), and play() lowers his hands for the length of the action before he covers his eyes again.
Jumps and waves leave his box. Give him headroom instead of clipping his container with overflow: hidden.
Props
Prop
Type
Default
expression
I9kNinoExpression
'idle'
look
'center' | 'up' | 'down' | 'start' | 'end'
'center'
size
'sm' | 'md' | 'lg' | 'auto'
'md'
animated
boolean
true
talking
boolean
false
shadow
boolean
false
label
string | null
null
Emits
Event
Payload
action-end
[result: I9kNinoActionResult]
Agent prompt
Use I9kNino from @9klabs/design to put the 9k mascot on a page. He is drawn entirely from rectangles and paths on a 32x32 pixel grid, so there is no image to load and nothing blurs at small sizes. Every motion is stepped like a sprite.
import { I9kNino, I9K_NINO_EXPRESSIONS, I9K_NINO_ACTIONS, type I9kNinoExposed } from '@9klabs/design';
Props:
- expression?: 'idle' | 'happy' | 'thinking' | 'worried' | 'surprised' | 'eyes-closed' | 'hiding' | 'dizzy' (default 'idle'). Only the eyes, brows, cheeks and mouth change, except 'hiding', where he covers his eyes with both hands for hide and seek and now and then peeks over the right one. 'dizzy' swirls his eyes into spirals and wobbles his mouth. A change plays a 240ms beat: his eyes shut, he hops, and the new face swaps in at 100ms.
- look?: 'center' | 'up' | 'down' | 'start' | 'end' (default 'center') — moves the eyes only. 'start' and 'end' follow the reading direction and swap under [dir="rtl"]. An idle Nino left at 'center' glances around on his own.
- size?: 'sm' | 'md' | 'lg' | 'auto' (default 'md') — 2rem / 3rem / 4.5rem, or 'auto' to drop the component's own CSS sizing.
- animated?: boolean (default true) — removes the class every animation rule hangs off when false.
- talking?: boolean (default false) — flaps his mouth between its own shape and an open frame.
- shadow?: boolean (default false) — a soft shadow under his feet, which shrinks while he jumps. Turn it on only where Nino stands on something; floating in a sky or sitting in a card, he has no ground to cast one on.
- label?: string | null (default null) — the localized name, e.g. 'Nino' or 'نينو'.
Emits: action-end with { action, completed } once per play() call, except a call still running when he unmounts, or one superseded by a call its own action-end handler made: those resolve with completed: false without emitting.
Exposes: play(action: 'wave' | 'jump' | 'nod' | 'shake' | 'dizzy'): Promise<{ action, completed }>. Hold a template ref and call it from your own events, e.g. a wave when the login page opens, a jump on success, a shake on a wrong password, a dizzy sway after a tumble. A newer call cuts the running one off, and the cut-off call settles with completed: false. With animated false or prefers-reduced-motion, play() moves nothing and resolves at once with completed: true.
Accessibility: with a `label` he renders role="img" and a <title>; without one he is aria-hidden="true". Pass `label` only when the mascot is content the reader would miss.
Colours are component-local custom properties you can override on one instance: --i9k-nino-body, --i9k-nino-screen, --i9k-nino-eye, --i9k-nino-mouth, --i9k-nino-cheek, --i9k-nino-glint.
IMPORTANT: to nest Nino inside your own <svg> and position him with x/y/width/height, pass size="auto". A jump or a raised arm draws outside his box on purpose (overflow: visible), so leave him some room above.
Usage:
<I9kNino ref="nino" expression="thinking" :talking="typing" size="lg" label="نينو" />
await nino.value.play('wave');
I9kNinoSky
A pixel night sky with Nino drifting through it: twinkling dotted stars, two dotted planets, eyes that follow the pointer, a boop (a new face, a spin and a burst of sparkles) on click, tap, Enter or Space, and a drag: pick Nino up, throw him around the window, and if he comes to rest above the sky he swoops back to it, dizzy. It has no background of its own and follows the page theme. Made for the space under a footer.
Small sky
<I9kNinoSky boop-label="Say hi to Nino" height="sm" />
Still (animated off)
<I9kNinoSky boop-label="Say hi to Nino" height="sm" :animated="false" />
Watch out
It has no background of its own and follows the page theme: light stars on a dark page, dark dots on a light one, fading in from its top edge. Put it on the page’s own background, not inside a card.
Put it outside I9kContainer: the sky is a full-bleed band, and the container would cut it to the page column.
It adds one tab stop (the boop button) with a visible focus ring.
It makes no sound and tracks nothing; listen to `boop` if your page wants to.
While airborne, Nino is `position: fixed` with z-index 300, so he flies over the page. An ancestor with a transform, filter, perspective, `contain: paint` or `will-change: transform` becomes his containing block and bounds him to itself instead of the window, so keep the sky out of one.
The Nino button sets `touch-action: none`: a swipe that starts on him drags him instead of scrolling the page. Swipes elsewhere in the sky still scroll.
Props
Prop
Type
Default
boopLabel*
string
—
height
'sm' | 'md' | 'lg'
'md'
animated
boolean
true
Emits
Event
Payload
boop
[expression: I9kNinoExpression]
Agent prompt
Use I9kNinoSky from @9klabs/design to end a page with a small moment of delight: a full-width pixel night sky with Nino floating in it.
import { I9kNinoSky } from '@9klabs/design';
Props:
- boopLabel: string (required) — the boop button's accessible name, localized by you, e.g. "Say hi to Nino" / "سلّم على نينو". The sky and Nino are decoration, so this is the scene's one name.
- height?: 'sm' | 'md' | 'lg' (default 'md') — 12rem / 18rem / 24rem.
- animated?: boolean (default true) — false stops the drift, twinkle, spin and sparkles, as prefers-reduced-motion does. Nino still looks at the pointer and changes his face.
Emits:
- boop: [expression] — fired on every click, tap, Enter or Space, with the face Nino just took.
Slots: none.
Behavior: the sky is a transparent <canvas> (aria-hidden) drawn on a 4px grid in the page theme's colours, fading in from its top edge; Nino is the real I9kNino inside a <button>. One animation loop, capped at 30 frames a second, runs only while the sky is on screen and the tab is visible, and never under reduced motion. Nothing the server renders carries a style attribute, so it works under a strict style-src CSP.
Drag and throw: pressing Nino and moving more than 4px picks him up; he follows the pointer anywhere in the window and is thrown with the pointer's speed when let go. In flight he floats (no gravity), slows down, and bounces off the window's edges. Coming to rest inside the sky, he drifts home; above it, he swoops straight back and plays I9kNino's dizzy expression and action. With animated false or reduced motion he can still be dragged, but there is no throw. A drag never boops; Enter and Space always do.
Usage, as a full-width band after the footer:
<I9kFooter ... />
<I9kNinoSky boop-label="Say hi to Nino" />
I9kChat
The shell of a scripted chat: a feature panel with a header (Nino, the bot name and actions), a scrolling live log of bubbles, and a sunken footer tray for the answer controls.
A scripted conversation
9k Labs
👋 Hi! A few quick questions, then a ballpark price.
What are you looking to build?
You: AI automation
How many of your systems does it connect to?
<I9kChat title="9k Labs" log-label="Conversation">
<template #actions><I9kButton size="sm" variant="link">↺ Start over</I9kButton></template>
<I9kChatBubble>👋 Hi! A few quick questions, then a ballpark price.</I9kChatBubble>
<I9kChatBubble>What are you looking to build?</I9kChatBubble>
<I9kChatBubble from="user" speaker-label="You">AI automation</I9kChatBubble>
<I9kChatBubble>How many of your systems does it connect to?</I9kChatBubble>
<template #footer>
<I9kChatOptions label="How many of your systems does it connect to?">
<I9kButton>0–1</I9kButton>
<I9kButton>2–3</I9kButton>
<I9kButton>4 or more</I9kButton>
</I9kChatOptions>
</template>
</I9kChat>
Typing, with a free-text answer
Ismail
Got it. And what's your name?
<I9kChat title="Ismail" log-label="Conversation" expression="thinking">
<I9kChatBubble>Got it. And what's your name?</I9kChatBubble>
<I9kChatBubble typing typing-label="Typing" />
<template #footer>
<I9kChatComposer v-model="name" label="Your name" placeholder="Your name" />
</template>
</I9kChat>
Watch out
Presentation only: the conversation state, branching and validation stay in your page.
Call the exposed scrollToEnd() after adding a bubble; the log scrolls inside the panel.
Set `expanded` once a tall result is shown so it is not clipped by the log height cap.
The footer tray only renders when the footer slot has content.
Props
Prop
Type
Default
logLabel*
string
—
title
string
''
expression
I9kNinoExpression
'idle'
expanded
boolean
false
Slots: avataractionsdefaultfooter
Agent prompt
Use I9kChat from @9klabs/design to render a guided, scripted conversation (an estimator, an intake form told as a chat). It is presentation only: your page owns the questions, answers, validation and analytics, and passes every string translated.
import { I9kChat, I9kChatBubble, I9kChatOptions, I9kChatComposer } from '@9klabs/design';
Props:
- logLabel: string (required) — accessible name of the role="log" region.
- title?: string — the bot name in the header.
- expression?: I9kNinoExpression (default 'idle') — Nino's face, e.g. 'thinking' while the bot types, 'happy' when done.
- expanded?: boolean (default false) — lifts the log's height cap (min(60vh, 34rem)) so a final result is never clipped.
Slots:
- default — the I9kChatBubble list.
- avatar — replaces Nino in the header.
- actions — header end, e.g. a "Start over" link button.
- footer — the answer tray (I9kChatOptions or I9kChatComposer). Leave it empty when nothing is being asked and the tray is not rendered.
Exposes: scrollToEnd() — call it after you append a message; the log scrolls, not the page.
IMPORTANT: the log is aria-live="polite", so screen readers announce new bubbles. Do not add another live region around it.
IMPORTANT: no strings are built in. Pass logLabel and title translated, and translate the labels of the bubble, options and composer too.
Usage:
<I9kChat ref="chat" title="9k Labs" log-label="Conversation" :expression="typing ? 'thinking' : 'idle'">
<template #actions><I9kButton size="sm" variant="link" @click="restart">↺ Start over</I9kButton></template>
<I9kChatBubble>What are you looking to build?</I9kChatBubble>
<I9kChatBubble from="user" speaker-label="You">A website</I9kChatBubble>
<template #footer>
<I9kChatOptions label="What are you looking to build?">
<I9kButton @click="answer('website')">A website</I9kButton>
</I9kChatOptions>
</template>
</I9kChat>
I9kChatBubble
One message in an I9kChat log: a bot bubble on the start side or a user bubble on the end side, with a typing indicator, a wide variant for rich results and an editable variant for revisiting an answer.
Bot, user, typing and wide
What are you looking to build?
You: A website
A wide bubble holds a result card.
<div style="display: grid; gap: var(--spacing-5)">
<I9kChatBubble>What are you looking to build?</I9kChatBubble>
<I9kChatBubble from="user" speaker-label="You">A website</I9kChatBubble>
<I9kChatBubble typing />
<I9kChatBubble wide>A wide bubble holds a result card.</I9kChatBubble>
</div>
Editable answer
<I9kChatBubble from="user" speaker-label="You" editable edit-label="Change your answer to: What's your name?">Ismail</I9kChatBubble>
Watch out
Pass `speakerLabel` on user bubbles; without it a screen reader hears two voices with no names.
An editable bubble is a button: give it an `editLabel` that names the question being changed.
Only user bubbles preserve line breaks.
Props
Prop
Type
Default
from
'bot' | 'user'
'bot'
typing
boolean
false
typingLabel
string
'Typing'
speakerLabel
string
''
wide
boolean
false
editable
boolean
false
editLabel
string
undefined
Emits
Event
Payload
edit
[]
Slots: defaultafter
Agent prompt
Use I9kChatBubble from @9klabs/design inside I9kChat for each message.
import { I9kChatBubble } from '@9klabs/design';
Props:
- from?: 'bot' | 'user' (default 'bot') — side, colour and tail corner. Corners and alignment are logical, so they mirror in Arabic.
- typing?: boolean — shows a three-dot indicator (role="status") instead of the content.
- typingLabel?: string (default 'Typing') — accessible name of that indicator; translate it.
- speakerLabel?: string — visually hidden prefix such as "You" so screen readers know who said it.
- wide?: boolean — spans the full log width, for a result card.
- editable?: boolean — renders the bubble as a <button> that emits `edit`, so the user can change that answer.
- editLabel?: string — accessible name of the editable bubble, e.g. "Change your answer to: What's your name?".
Emits: edit() — the editable bubble was activated.
Slots: default — the message; after — content under the bubble on its side, e.g. a "↩ Change" link.
Behavior: user bubbles keep line breaks (white-space: pre-wrap). Bot bubbles fade in unless the visitor prefers reduced motion.
Usage:
<I9kChatBubble>What should the AI do?</I9kChatBubble>
<I9kChatBubble from="user" speaker-label="You">Answer questions from our documents
<template #after><button type="button" @click="back">↩ Change</button></template>
</I9kChatBubble>
Forms
I9kChatComposer
The free-text answer of an I9kChat footer: a rounded field with a send button. Enter sends; a multiline composer grows with the text and takes Shift+Enter for a new line.
Single line
<I9kChatComposer v-model="name" label="What's your name?" placeholder="Your name" />
Multiline, invalid
<I9kChatComposer v-model="summary" label="What do you need?" multiline invalid />
Watch out
The composer does not clear itself after submit; reset the bound value when the answer is accepted.
Blank and whitespace-only input never emits `submit`.
Error text is yours to render; `invalid` only styles the field and sets aria-invalid.
Props
Prop
Type
Default
modelValue
string
''
label*
string
—
placeholder
string
''
sendLabel
string
'Send'
multiline
boolean
false
maxlength
number
undefined
invalid
boolean
false
disabled
boolean
false
Emits
Event
Payload
update:modelValue
[value: string]
submit
[value: string]
Agent prompt
Use I9kChatComposer from @9klabs/design in the footer slot of I9kChat when the answer is typed.
import { I9kChatComposer } from '@9klabs/design';
Props:
- modelValue: string — bind with v-model.
- label: string (required) — accessible name of the field; use the question text.
- placeholder?: string.
- sendLabel?: string (default 'Send') — accessible name of the send button; translate it.
- multiline?: boolean — a textarea that grows up to 10rem; Shift+Enter inserts a line break.
- maxlength?: number.
- invalid?: boolean — accent border and aria-invalid; render your error message next to it.
- disabled?: boolean.
Emits:
- update:modelValue(value: string).
- submit(value: string) — Enter or the send button, only for non-blank input. Validate and clear the model yourself.
Exposes: focus().
Usage:
<p v-if="error" role="alert">{{ error }}</p>
<I9kChatComposer v-model="draft" label="What's your name?" placeholder="Your name" :invalid="!!error" @submit="answer" />
I9kField
Field wrapper that renders a label, hint, and error, and provides id/size/validity context to one nested control. It is the provider side of the field composable; I9kInput, I9kTextarea, and I9kSelect are its consumers.
Wrapping I9kInput
We never share it.
<I9kField label="Email" hint="We never share it."><I9kInput v-model="email" /></I9kField>
<I9kField label="Email" required error="This field is required.">
<I9kInput v-model="email" />
</I9kField>
Watch out
Nest exactly one control — I9kField only tracks and warns (in dev) if more than one registers.
Inside I9kField, do not pass label, hint, error, or uiSize/size to I9kInput, I9kTextarea, or I9kSelect: I9kField supplies them through the field composable.
I9kRadioGroup ignores I9kField entirely — it never calls useI9kField(), so wrapping it here has no effect and produces an orphaned label/hint/error alongside the group's own legend/hint/error. Pass legend, hint, error, and size straight to I9kRadioGroup instead.
Props
Prop
Type
Default
label
string
''
hint
string
undefined
error
string
undefined
required
boolean
false
size
'sm' | 'md' | 'lg'
'md'
controlId
string
undefined
Slots: labeldefault
Agent prompt
Use I9kField from @9klabs/design to wrap a single form control with a label, hint, and error.
import { I9kField } from '@9klabs/design';
Props:
- label?: string (default '') — ignored if the #label slot is used instead.
- hint?: string
- error?: string — a defined value renders the error message instead of the hint and marks the control invalid.
- required?: boolean (default false) — shows a trailing "*" next to the label.
- size?: 'sm' | 'md' | 'lg' (default 'md')
- controlId?: string — supply to pin the id instead of the auto-generated one.
Emits: none.
Slots:
- label — overrides the label prop's content.
- default — scoped slot exposing { controlId, describedBy, invalid, required, size }; the nested control reads these.
IMPORTANT: nest exactly one form control in the default slot. I9kInput, I9kTextarea, and I9kSelect read this context automatically via useI9kField() — inside I9kField, omit their own label/hint/error/uiSize props, since I9kField owns and renders those. I9kRadioGroup does NOT consume this context (it has its own legend/hint/error/size props) — do not wrap it in I9kField.
For a raw native control instead of a package component, bind the scoped slot props by hand: :id="controlId", :aria-describedby="describedBy", :aria-invalid="invalid", :required="required".
Usage:
<I9kField label="Email" hint="We never share it."><I9kInput v-model="email" /></I9kField>
I9kCheckboxGroup
Accessible checkbox fieldset for selecting multiple values with labels, descriptions, validation, and RTL support.
<I9kCheckboxGroup
:model-value="[]"
:options="[{ label: 'Engineering', value: 'engineering' }, { label: 'Design', value: 'design' }]"
legend="Choose your interests"
error="Choose at least one interest."
required
/>
Arabic RTL
<div dir="rtl" lang="ar">
<I9kCheckboxGroup
:model-value="['engineering']"
:options="[{ label: 'هندسة البرمجيات', value: 'engineering' }, { label: 'تصميم المنتجات', value: 'design' }]"
legend="اختر اهتماماتك"
hint="يمكنك اختيار أكثر من مجال"
/>
</div>
Watch out
`modelValue` is an array because multiple options may be selected; the emitted array is new and never mutates the prop.
Use `legend` as the group label instead of adding a separate, unassociated heading.
An option-level `disabled` value combines with the group-level `disabled` prop.
Props
Prop
Type
Default
modelValue*
readonly string[]
—
options*
readonly I9kCheckboxOption[]
—
legend*
string
—
name
string
undefined
hint
string
undefined
error
string
undefined
required
boolean
false
disabled
boolean
false
size
'sm' | 'md' | 'lg'
'md'
orientation
'horizontal' | 'vertical'
'vertical'
Emits
Event
Payload
update:modelValue
[value: string[]]
Agent prompt
Use I9kCheckboxGroup from @9klabs/design when a user may select zero, one, or several options.
import { I9kCheckboxGroup } from '@9klabs/design';
import type { I9kCheckboxOption } from '@9klabs/design';
Props:
- modelValue: readonly string[] (required) — selected option values; use v-model in interactive forms.
- options: readonly I9kCheckboxOption[] (required) — { label, value, description?, disabled? }.
- legend: string (required) — accessible fieldset name.
- name?: string — shared native checkbox name; generated when omitted.
- hint?: string — supporting text announced with the group.
- error?: string — validation message announced as an alert.
- required?: boolean (default false) — requires at least one enabled selection.
- disabled?: boolean (default false) — disables the whole fieldset.
- size?: 'sm' | 'md' | 'lg' (default 'md').
- orientation?: 'horizontal' | 'vertical' (default 'vertical').
Emits:
- update:modelValue(value: string[]) — returns a new array after a native checkbox change.
Slots: none.
Behavior: the component renders a native fieldset and checkbox inputs. Required state clears from individual inputs after one enabled option is selected, while disabled options remain unavailable.
Usage:
<I9kCheckboxGroup
v-model="selected"
:options="[{ label: 'Engineering', value: 'engineering' }, { label: 'Design', value: 'design' }]"
legend="Choose your interests"
/>
I9kInput
Single-line text input with an optional label, hint, and error state. Wires its own accessible ids, and inherits size and error state from a wrapping I9kField when there is one.
<I9kInput v-model="email" label="Email" hint="We never share it." />
<I9kInput v-model="email" label="Email" error="That address is not valid." />
Watch out
The visual scale prop is `uiSize`, not `size` — `size` passes through to the native input attribute.
Inside an I9kField, omit `label`, `hint`, `error`, and `uiSize`: the field supplies them and owns the ids.
Props
Prop
Type
Default
modelValue*
string
—
label
string
undefined
type
'text' | 'email' | 'password'
'text'
error
string | null
null
hint
string
undefined
required
boolean
false
uiSize
'sm' | 'md' | 'lg'
undefined
Emits
Event
Payload
update:modelValue
[value: string]
Agent prompt
Use I9kInput from @9klabs/design for a labelled single-line text field.
import { I9kInput } from '@9klabs/design';
Props:
- modelValue: string (required) — the v-model target.
- label?: string
- type?: 'text' | 'email' | 'password' (default 'text')
- error?: string | null (default null) — a non-null value renders the error state and wires aria-describedby.
- hint?: string
- required?: boolean (default false)
- uiSize?: 'sm' | 'md' | 'lg' — falls back to a wrapping I9kField's size, then to 'md'.
Emits: update:modelValue with the new string.
IMPORTANT: the visual scale prop is `uiSize`, NOT `size`. `size` is left free for the native HTML attribute and is forwarded to the underlying <input>.
Usage:
<I9kInput v-model="email" label="Email" type="email" ui-size="md" hint="We never share it." />
I9kTextarea
Multi-line text control for longer form input. Wires its own id and ARIA attributes standalone, and inherits id, size, and error state from a wrapping I9kField when there is one.
Inside I9kField
A few sentences is plenty.
<I9kField label="Project details" hint="A few sentences is plenty.">
<I9kTextarea v-model="details" />
</I9kField>
Inside an I9kField, do not pass id, aria-invalid, or aria-describedby — the field supplies them; a conflicting id logs a dev warning.
Without a wrapping I9kField, supply aria-label or aria-labelledby yourself — there is no visible label otherwise.
Props
Prop
Type
Default
modelValue*
string
—
uiSize
'sm' | 'md' | 'lg'
undefined
resize
'vertical' | 'horizontal' | 'both' | 'none'
'vertical'
Emits
Event
Payload
update:modelValue
[value: string]
Agent prompt
Use I9kTextarea from @9klabs/design for multi-line text input, typically inside an I9kField.
import { I9kTextarea } from '@9klabs/design';
Props:
- modelValue: string (required) — the v-model target.
- uiSize?: 'sm' | 'md' | 'lg' — falls back to a wrapping I9kField's size, then to 'md'.
- resize?: 'vertical' | 'horizontal' | 'both' | 'none' (default 'vertical')
Emits: update:modelValue with the new string.
Slots: none — this renders a bare <textarea>.
Behavior: inside an I9kField, I9kTextarea calls useI9kField() and takes its id, described-by ids, invalid state, required state, and size from that context automatically. Standalone (no wrapping I9kField), it generates its own id and expects the caller to provide an accessible name via aria-label or aria-labelledby — omitting both triggers a dev-mode console warning. required, aria-invalid, and aria-describedby also pass through as native attrs when there is no I9kField.
IMPORTANT: the visual scale prop is `uiSize`, not `size`. IMPORTANT: inside I9kField, do not pass id, aria-invalid, or aria-describedby — I9kField supplies them, and a mismatched id triggers a dev-mode warning.
Usage:
<I9kField label="Project details"><I9kTextarea v-model="details" /></I9kField>
I9kSelect
Native single-select dropdown that auto-selects the option matching modelValue. Wires its own id and ARIA attributes standalone, and inherits id, size, and error state from a wrapping I9kField when there is one.
<I9kField label="Service" error="Select the service you need.">
<I9kSelect v-model="service">
<option value="">Choose a service</option>
<option value="audit">Technical audit</option>
</I9kSelect>
</I9kField>
Watch out
The visual scale prop is `uiSize`, not `size`; the native `size` and `multiple` attributes are stripped and logged as a dev warning.
Pass plain <option>/<optgroup> children without a `selected` attribute — I9kSelect sets it based on modelValue matching the option's value or text.
Inside an I9kField, do not pass id, aria-invalid, or aria-describedby — the field supplies them.
Props
Prop
Type
Default
modelValue*
string
—
uiSize
'sm' | 'md' | 'lg'
—
Emits
Event
Payload
update:modelValue
[value: string]
Agent prompt
Use I9kSelect from @9klabs/design for a single-choice dropdown, typically inside an I9kField.
import { I9kSelect } from '@9klabs/design';
Props:
- modelValue: string (required) — the v-model target, matched against each child <option>'s value (or its text content if it has no value attribute).
- uiSize?: 'sm' | 'md' | 'lg' — falls back to a wrapping I9kField's size, then to 'md'.
Emits: update:modelValue with the new string.
Slots:
- default — plain <option> and <optgroup> elements. I9kSelect clones them and sets `selected` on the one matching modelValue; do not set `selected` yourself.
Behavior: inside an I9kField, I9kSelect calls useI9kField() and takes its id, described-by ids, invalid state, required state, and size from that context automatically. Standalone, it generates its own id and expects an accessible name via aria-label or aria-labelledby — omitting both triggers a dev-mode console warning. This is a native single-select only: the `multiple` and `size` HTML attributes are stripped and log a dev-mode warning if passed.
IMPORTANT: the visual scale prop is `uiSize`, not `size` — passing the native `size` attribute is rejected with a dev warning. IMPORTANT: inside I9kField, do not pass id, aria-invalid, or aria-describedby — I9kField supplies them.
Usage:
<I9kField label="Service"><I9kSelect v-model="service"><option value="">Choose one</option><option value="audit">Technical audit</option></I9kSelect></I9kField>
I9kPhoneInput
Phone number input with a compact country-code picker: a flag-and-code face over a transparent native select that lists every country by name. Registers the number, not the picker, with a wrapping I9kField.
The component only collects the country and the typed number; it does not validate or normalise them.
The server renders only the selected country; the named list appears after hydration.
Inside an I9kField, do not pass id, aria-invalid, or aria-describedby — the field supplies them to the number.
Use autocomplete="tel-national", not "tel": "tel" autofills the number with its country code, which the picker already shows.
disabled freezes both the number and the country; readonly has no <select> equivalent and freezes only the number.
Props
Prop
Type
Default
modelValue*
string
—
country*
string
—
countryLabel*
string
—
locale
string
'en'
countryName
string
undefined
uiSize
'sm' | 'md' | 'lg'
undefined
Emits
Event
Payload
update:modelValue
[value: string]
update:country
[country: string]
Agent prompt
Use I9kPhoneInput from @9klabs/design for a phone number that needs a country calling code, inside an I9kField.
import { I9kField, I9kPhoneInput } from '@9klabs/design';
Props:
- modelValue: string (required) — the number as typed, v-model target. The component does not parse or validate it; combine it with the country yourself (libphonenumber-js is a good fit).
- country: string (required) — ISO 3166-1 alpha-2 code, v-model:country target. Must be a key of I9K_COUNTRY_DIAL_CODES.
- countryLabel: string (required) — accessible name of the country select, e.g. "Country code".
- locale?: string — language the country names are listed in (default 'en'). Pass the page locale.
- countryName?: string — name attribute for the country select.
- uiSize?: 'sm' | 'md' | 'lg' — falls back to a wrapping I9kField's size, then to 'md'.
Emits: update:modelValue with the typed string; update:country with the picked code.
Behavior: inside an I9kField, only the number registers with the field, so the field's label, hint and error describe the number; the select is named by countryLabel. Other attributes (name, autocomplete, placeholder) go to the number input, which is type="tel"; class and style go to the wrapper. The control is always dir="ltr" so the code sits before the number in Arabic too. Standalone, give the number an accessible name with aria-label or aria-labelledby.
IMPORTANT: the named country list is rendered only after mount — the server renders just the selected code — because server and browser ICU name and sort countries differently and would otherwise hydrate out of step. Do not rely on the full option list in server-rendered HTML.
Usage:
<I9kField label="WhatsApp number"><I9kPhoneInput v-model="phone" v-model:country="country" country-label="Country code" name="phone" autocomplete="tel-national" /></I9kField>
I9kRadioGroup
Fieldset of mutually exclusive radio options, rendered as a stacked list or a card grid. Unlike I9kInput, I9kTextarea, and I9kSelect, it does not participate in the I9kField composable — it owns its own legend, hint, error, and ids entirely.
Default
<I9kRadioGroup v-model="service" legend="Choose a service" :options="options" />
Card variant
<I9kRadioGroup
v-model="service"
legend="Choose a service"
variant="card"
:options="options"
/>
Required with error
<I9kRadioGroup
v-model="service"
legend="Choose a service"
required
error="Choose the service you need."
:options="options"
/>
Watch out
Never wrap I9kRadioGroup in I9kField — it does not call useI9kField() and will not inherit label, hint, error, or size from it; pass those as its own props instead.
`options` must match the I9kRadioOption shape exactly: { label, value, description?, disabled? }.
The `card` variant lays out options in a two-column grid (one column under 640px) with the native input visually hidden; `default` renders a plain stacked or wrapped list.
Props
Prop
Type
Default
modelValue*
string
—
options*
readonly I9kRadioOption[]
—
legend*
string
—
name
string
undefined
hint
string
undefined
error
string
undefined
required
boolean
false
disabled
boolean
false
size
'sm' | 'md' | 'lg'
'md'
variant
'default' | 'card'
'default'
orientation
'horizontal' | 'vertical'
'vertical'
Emits
Event
Payload
update:modelValue
[value: string]
Agent prompt
Use I9kRadioGroup from @9klabs/design for a single choice among a small, fully visible set of options.
import { I9kRadioGroup } from '@9klabs/design';
import type { I9kRadioOption } from '@9klabs/design';
Props:
- modelValue: string (required) — the v-model target, matched against each option's value.
- options: readonly I9kRadioOption[] (required) — each is { label: string; value: string; description?: string; disabled?: boolean }.
- legend: string (required) — the fieldset's accessible name; always rendered, there is no slot override.
- name?: string — the radio input group name; defaults to an auto-generated id.
- hint?: string
- error?: string — a defined value renders the error message instead of the hint and marks the group invalid.
- required?: boolean (default false)
- disabled?: boolean (default false) — disables the whole group.
- size?: 'sm' | 'md' | 'lg' (default 'md')
- variant?: 'default' | 'card' (default 'default')
- orientation?: 'horizontal' | 'vertical' (default 'vertical')
Emits: update:modelValue with the selected option's value.
Slots: none.
IMPORTANT: I9kRadioGroup never calls useI9kField() — do NOT wrap it in I9kField expecting it to inherit label, hint, error, or size the way I9kInput/I9kTextarea/I9kSelect do. It renders its own <fieldset><legend> and its own hint/error paragraphs directly from its own props. Wrapping it in I9kField still renders I9kField's separate label/hint/error around the group, producing duplicated, disconnected markup — pass legend, hint, error, required, and size straight to I9kRadioGroup instead, with no I9kField involved.
Usage:
<I9kRadioGroup v-model="service" legend="Choose a service" :options="[{ label: 'Technical audit', value: 'audit' }, { label: 'Design system', value: 'design' }]" />
Actions
I9kChatOptions
The answer row of an I9kChat footer: a named group of reply buttons aligned to the user side, with a focus() handle so the conversation can move focus to the next question.
Single choice
<I9kChatOptions label="How many of your systems does it connect to?">
<I9kButton>0–1</I9kButton>
<I9kButton>2–3</I9kButton>
<I9kButton>4 or more</I9kButton>
</I9kChatOptions>
Watch out
The group needs a `label`; use the question being answered.
Key the component by question so a multi-select starts empty for each one.
Props
Prop
Type
Default
label*
string
—
Slots: default
Agent prompt
Use I9kChatOptions from @9klabs/design in the footer slot of I9kChat when the answer is a choice.
import { I9kChatOptions } from '@9klabs/design';
Props:
- label: string (required) — accessible name of the group; use the question text.
Slots: default — the reply buttons (usually I9kButton; use variant="filter" with :active for multi-select).
Exposes: focus() — focuses the first enabled control. Call it after the bot asks the next question so keyboard users land on the answers.
Behavior: buttons wrap and align to the end side (the user's side), mirroring in Arabic.
Usage:
<I9kChatOptions ref="options" label="What are you looking to build?">
<I9kButton @click="answer('website')">A website</I9kButton>
<I9kButton @click="answer('app')">An app</I9kButton>
</I9kChatOptions>
I9kButton
Polymorphic action trigger: renders a native button by default, an anchor when given a destination, or a caller-supplied component. Six variants cover primary actions, filters, and pagination.
Use I9kButton from @9klabs/design for any clickable action or link styled as a button.
import { I9kButton } from '@9klabs/design';
Props:
- to?: string | Record<string, unknown> | null (default null) — a route-like destination. Setting this makes the root render as `<a>` (or `linkComponent` if given).
- href?: string | null (default null) — a plain URL. Setting this also makes the root render as `<a>`.
- variant?: 'default' | 'primary' | 'link' | 'filter' | 'pagination' | 'page' (default 'default')
- size?: 'sm' | 'md' | 'lg' (default 'md')
- active?: boolean (default false) — toggles the selected look; used by 'filter' and 'page' variants.
- type?: 'button' | 'submit' | 'reset' (default 'button') — only applies when the root renders as a native <button>; it is dropped when `to` or `href` is set.
- linkComponent?: string | object | null (default null) — a component to render instead of `<a>` when `to` is set, e.g. a router link component.
Emits: none. It forwards native events (click, etc.) as ordinary DOM listeners via `v-bind`/attribute fallthrough.
Slots: default — the button's content.
Design: primary and default actions share the same shape and padding. Filters use pills; selected filters and pages use a green tint. Group adjacent actions with I9kCluster.
Busy native buttons: pass disabled and aria-busy="true", and use an action-specific label such as "Saving…". These are native attributes, not a loading prop. aria-busy and aria-disabled alone do not suppress clicks or link navigation; consumers must manage interaction for custom link components.
Root element rule: with no `to`/`href`/`linkComponent`, I9kButton renders `<button>`. Passing `to` or `href` switches it to `<a>`. Passing `linkComponent` renders that component instead, and it receives `to` — use this in a Vue Router app by setting `link-component="RouterLink"` so internal navigation goes through the router instead of a full page load.
IMPORTANT: `type` only takes effect on the native `<button>` form. If `to` or `href` is set, `type` is not rendered — do not rely on it to distinguish submit buttons that are also links.
Usage:
<I9kButton variant="primary" @click="onSave">Save</I9kButton>
<I9kButton to="/pricing" link-component="RouterLink">See pricing</I9kButton>
I9kButtonGroup
Layout wrapper that spaces a row or column of buttons with a consistent gap and groups them as one control for assistive tech.
`size` on I9kButtonGroup only changes the gap between children — set the same `size` on each child button to actually resize them.
Give it a `label` when the group has no adjacent visible heading; it becomes the `aria-label` on the `role="group"` wrapper.
`orientation="vertical"` stretches children to fill the group's width (`align-items: stretch`); horizontal (the default) wraps children onto new lines instead of overflowing.
Props
Prop
Type
Default
size
'sm' | 'md' | 'lg'
'md'
orientation
'horizontal' | 'vertical'
'horizontal'
label
string
undefined
Slots: default
Agent prompt
Use I9kButtonGroup from @9klabs/design to lay out a cluster of related buttons (e.g. Save/Cancel, or a toolbar of icon buttons) with consistent spacing.
import { I9kButtonGroup } from '@9klabs/design';
Props:
- size?: 'sm' | 'md' | 'lg' (default 'md') — sets the gap between children only.
- orientation?: 'horizontal' | 'vertical' (default 'horizontal') — horizontal wraps onto new lines; vertical stretches children to full width.
- label?: string — sets the group's accessible name (`aria-label`) via `role="group"`. Omit it and no `aria-label` is rendered.
Emits: none.
Slots: default — the buttons (typically I9kButton and/or I9kIconButton).
IMPORTANT: `size` only controls the gap between children — it does NOT resize the buttons inside. Set `size` on each child button to match if you want them visually smaller or larger, not just on the group.
Usage:
<I9kButtonGroup label="Article actions" size="sm">
<I9kButton size="sm">Save</I9kButton>
<I9kIconButton icon="mail" label="Email article" size="sm" />
</I9kButtonGroup>
I9kIconButton
Circular icon-only action trigger: renders a native button by default, or an anchor/caller-supplied component when given a destination. Its own variant set is separate from I9kButton.
<I9kIconButton icon="github" label="View on GitHub" href="https://github.com/ismail9k" variant="ghost" />
Watch out
I9kIconButton's variant union ('secondary' | 'primary' | 'ghost') is distinct from I9kButton's — never reuse I9kButton variants like 'default' or 'filter' here.
`label` is required and must be non-empty: it is the sole accessible name since the button renders an icon only, no text.
The root element depends on props, not a mode flag: pass `to` or `href` for a link, omit both for a native button, same convention as I9kButton.
Props
Prop
Type
Default
icon*
I9kIconName
—
label*
string
—
to
string | Record<string, unknown> | null
null
href
string | null
null
variant
'secondary' | 'primary' | 'ghost'
'secondary'
size
'sm' | 'md' | 'lg'
'md'
type
'button' | 'submit' | 'reset'
'button'
linkComponent
string | object | null
null
Agent prompt
Use I9kIconButton from @9klabs/design for a compact, icon-only action (e.g. a toolbar button or a social/contact link) that needs no visible text label.
import { I9kIconButton } from '@9klabs/design';
Props:
- icon: I9kIconName (required) — a name from src/icons/paths.json, e.g. 'mail', 'home', 'menu'.
- label: string (required) — the accessible name, rendered as `aria-label`. There is no visible text, so this must be non-empty and descriptive; the component logs a dev warning if it is blank.
- to?: string | Record<string, unknown> | null (default null) — a route-like destination. Setting this makes the root render as `<a>` (or `linkComponent` if given).
- href?: string | null (default null) — a plain URL. Setting this also makes the root render as `<a>`.
- variant?: 'secondary' | 'primary' | 'ghost' (default 'secondary')
- size?: 'sm' | 'md' | 'lg' (default 'md') — also scales the inner icon.
- type?: 'button' | 'submit' | 'reset' (default 'button') — only applies when the root renders as a native <button>; dropped when `to` or `href` is set.
- linkComponent?: string | object | null (default null) — a component to render instead of `<a>` when `to` is set, e.g. a router link component.
Emits: none. It forwards native events (click, etc.) as ordinary DOM listeners via `v-bind`/attribute fallthrough.
Slots: none — content is always the icon; there is no default slot for text.
IMPORTANT: I9kIconButton has its OWN variant type, 'secondary' | 'primary' | 'ghost' — this is NOT the same union as I9kButton's variant ('default' | 'primary' | 'link' | 'filter' | 'pagination' | 'page'). Do not pass I9kButton variants like 'default' or 'filter' here; only 'secondary', 'primary', or 'ghost' are valid.
IMPORTANT: `label` is required and must be non-empty — it is the button's only accessible name since it renders no visible text.
Usage:
<I9kIconButton icon="mail" label="Email us" variant="primary" @click="onEmail" />
<I9kIconButton icon="github" label="View on GitHub" href="https://github.com/ismail9k" variant="ghost" />
I9kTabs
Accessible horizontal tabs (the WAI-ARIA tabs pattern with automatic activation): a named tablist of equal-width tabs over the one panel that belongs to the selected tab.
Nine modules, from the first prompt to a shipped project.
<I9kTabs v-model="section" :tabs="sections" label="Course details" size="sm">
<template #overview><p>Nine modules, from the first prompt to a shipped project.</p></template>
<template #curriculum><p>Each module ends with a hands-on exercise.</p></template>
</I9kTabs>
Watch out
Only the selected panel is rendered, so its content remounts with fresh state on every switch; keep state that must survive a switch in the parent.
`label` is required: it names the tablist for screen readers.
Set `:focusable-panel="false"` (bound, not a plain attribute) when the panel starts with a focusable field, so the panel is not an extra tab stop.
Arrow keys mirror in a right-to-left context: ArrowLeft moves to the next tab.
Bind `v-model`; a value naming no enabled tab shows the first enabled tab without emitting.
Props
Prop
Type
Default
modelValue*
string
—
tabs*
I9kTabItem[]
—
label*
string
—
size
'sm' | 'md' | 'lg'
'md'
focusablePanel
boolean
true
Emits
Event
Payload
update:modelValue
[value: string]
Slots: [selectedTab.value]
Agent prompt
Use I9kTabs from @9klabs/design to switch between a few alternative views in one place, such as "I have an account" and "New account" in a sign-in dialog.
import { I9kTabs } from '@9klabs/design';
import type { I9kTabItem } from '@9klabs/design';
Props:
- modelValue: string (required) — bind with v-model; the selected tab's `value`.
- tabs: I9kTabItem[] (required) — { value: string; label: string; disabled?: boolean }. `value` names the tab's slot; `label` is its visible text and accessible name; a disabled tab cannot be selected and the arrow keys skip it.
- label: string (required) — the tablist's accessible name (aria-label); translate it.
- size?: 'sm' | 'md' | 'lg' (default 'md') — the tab height, text size, and the gap above the panel.
- focusablePanel?: boolean (default true) — gives the panel tabindex="0" so keyboard users can reach content that holds nothing focusable. Set it to false when the panel starts with a form field.
Emits:
- update:modelValue(value: string) — the user selected another tab, by click or keyboard.
Slots:
- one named slot per tab `value`, e.g. <template #login="{ value }">. It receives { value } and holds that tab's panel content.
Behavior: tabs share the row width equally. Clicking a tab selects it. On a focused tab, ArrowRight and ArrowLeft move to the next and previous enabled tab, wrapping at both ends, and Home and End go to the first and last; moving selects the tab and focuses it. In a right-to-left context (the tab's computed direction is rtl) the arrows are mirrored, so ArrowLeft moves to the next tab. A modelValue that matches no enabled tab shows the first enabled tab, without emitting.
IMPORTANT: only the selected tab's panel is rendered, so its content unmounts when another tab is selected and mounts again, with fresh state, when it is selected again. Keep state that must survive a switch in the parent.
IMPORTANT: `label` is required because a tablist must have a name; do not repeat the tab labels in it.
IMPORTANT: bind v-model. Without an update:modelValue listener, defineModel keeps the selection locally and the parent never hears of it.
Usage:
<I9kTabs
v-model="mode"
:tabs="[
{ value: 'login', label: 'I have an account' },
{ value: 'signup', label: 'New account' },
]"
label="Sign in"
:focusable-panel="false"
>
<template #login><LoginForm /></template>
<template #signup><SignupForm /></template>
</I9kTabs>
Feedback
I9kToast
Styled, accessible message banner for standing status or error text in the page flow. For notifications (saved, failed, sent), use I9kToaster, which renders I9kToast for each one.
Variants
Your changes are syncing.
Changes saved.
You have unsaved changes.
Could not save changes.
<I9kToast variant="info">Your changes are syncing.</I9kToast>
<I9kToast variant="success">Changes saved.</I9kToast>
<I9kToast variant="warning">You have unsaved changes.</I9kToast>
<I9kToast variant="error">Could not save changes.</I9kToast>
Warning
You have unsaved changes.
<I9kToast variant="warning">You have unsaved changes.</I9kToast>
For notifications, use I9kToaster; I9kToast alone stays for standing status in the page flow. It renders no dismiss control and sets no timer — it is a static banner until you remove it from the DOM.
`variant="error"` renders `role="alert"` (assertive); `info`, `success` and `warning` render `role="status"` (polite). A warning is usually a standing state of the page (for example "this course has no published lessons"), which should not interrupt a screen reader each time the page renders — use `error` only for a failure the user must react to now.
It has no positioning of its own. Do not wrap it in your own `position: fixed` container to make it float — that is I9kToaster, which also keeps it readable while an I9kModal is open.
`:live="false"` (a binding, not the string "false") drops the role entirely. Use it only when the text is announced elsewhere, as I9kToaster does; a lone toast without a role is never announced.
Props
Prop
Type
Default
variant
'info' | 'success' | 'warning' | 'error'
'info'
size
'sm' | 'md' | 'lg'
'md'
live
boolean
true
Slots: default
Agent prompt
Use I9kToast from @9klabs/design to display a short status or error message with a live-region role wired in automatically.
import { I9kToast } from '@9klabs/design';
Props:
- variant?: 'info' | 'success' | 'warning' | 'error' (default 'info') — also sets the ARIA role: 'error' renders `role="alert"`; 'info', 'success' and 'warning' render `role="status"`.
- size?: 'sm' | 'md' | 'lg' (default 'md')
- live?: boolean (default true) — false renders no role at all, for a toast whose text is announced elsewhere (I9kToaster sets it, because its own live regions announce each notification).
Emits: none.
Slots: default — the message content.
IMPORTANT: for notifications, use I9kToaster (app.use(createI9kToaster()), one <I9kToaster /> at the app root, useI9kToaster().show(...)) — it floats, stacks, times out and announces them. I9kToast alone has no dismiss button, no auto-hide timer and no positioning: it stays for standing status in the page flow, such as "this course has no published lessons".
Usage:
<I9kToast variant="success">Changes saved.</I9kToast>
I9kToaster
The one place notifications appear: a fixed stack at the top of the viewport that shows the toasts raised through a per-app store, announces each through its own live regions, dismisses info and success after five seconds and keeps warnings and errors until the user dismisses them.
Success and error
<!-- toaster = useI9kToaster(), from app.use(createI9kToaster()) -->
<I9kToaster />
<I9kButton @click="toaster.show({ variant: 'success', message: 'Changes saved.' })">
Save
</I9kButton>
<I9kButton
@click="toaster.show({
id: 'save',
variant: 'error',
message: 'Could not save changes.',
detail: 'تعذّر الاتصال بالخادم',
detailLang: 'ar',
detailDir: 'rtl',
})"
>
Fail to save
</I9kButton>
Watch out
Render exactly one `<I9kToaster />` per app root, before the page content (above the router view): a toast raised during a page’s setup before the toaster’s own setup ran is shown but never announced. I9kModal and I9kSidebarLayout’s drawer add their own while open, so never place one inside either yourself.
`useI9kToaster()` and `useI9kToastSource()` throw without an installed store: call `app.use(createI9kToaster())` (a Nuxt plugin) first. The store is per app, so server renders never share notifications.
Warnings and errors are sticky: they stay until the user dismisses them or code dismisses them. A `useI9kToastSource()` toast also goes when its getter returns nothing or its component unmounts; a `toaster.show()` toast outlives the component that raised it, so dismiss it yourself. Pass `duration` to change that.
Set labels on the store (`createI9kToaster({ labels })`, or assign `toaster.labels.region` / `.dismiss`), not as props on the page `<I9kToaster>`: an open I9kModal’s toaster reads only the store. `labels` is read-only as a whole, so assign its fields rather than the object.
`useI9kToastSource()` re-shows only when the getter’s value changes: setting a ref to the text it already holds shows nothing, so clear it at the start of each attempt. A change in anything else the getter reads (a `t()` locale switch) re-shows a toast the user dismissed.
Toasts are plain text (`message`, optional `detail`) — no slots, links or buttons inside them. Mark a detail in another language with `detailLang` and `detailDir`.
A toaster never announces a toast raised before it appeared, so opening a modal does not repeat the page’s last notification, and only the toaster that was active when a toast was raised announces it, so the page does not repeat a toast raised in a modal, neither while the modal is open nor once it closes. A toast raised in the same tick a modal closes, which the modal never announced, is announced by the page once the dialog has closed: no need to wait for the close before raising a result.
Do not render the same message inline as well; move it to the toaster.
Props
Prop
Type
Default
toaster
I9kToasterStore | null
null
label
string
undefined
dismissLabel
string
undefined
size
'sm' | 'md' | 'lg'
'md'
Agent prompt
Use I9kToaster from @9klabs/design to show notifications (saved, failed, sent) as floating toasts instead of banners in the page flow.
import { createI9kToaster, I9kToaster, useI9kToaster, useI9kToastSource } from '@9klabs/design';
Install once per app, then render exactly one <I9kToaster /> at the app root, before the page content in the tree (in the root layout, above the router view). A toaster never announces a toast raised before its own setup ran, so a toast raised during the setup of a page rendered above it is shown but never announced:
// main.ts (in Nuxt, a plugin: nuxtApp.vueApp.use(createI9kToaster({ labels })))
app.use(createI9kToaster({ labels: { region: 'Notifications', dismiss: 'Dismiss' } }));
<!-- App.vue -->
<I9kToaster />
<RouterView />
Translate through the store, not through props: an open I9kModal renders its own toaster, which reads only the store and its labels. `toaster.labels` is reactive and read-only as a whole — assign its fields (toaster.labels.region = t('notifications.region'), or Object.assign(toaster.labels, { region, dismiss })) when the locale changes; replacing the object is a type error and would not update anything.
Raise a toast from a ref you already have — the toast follows the ref: it shows while the getter returns options, is shown again when they change, and is dismissed when the getter returns null or the component unmounts:
const error = ref<string | null>(null);
useI9kToastSource(() => (error.value ? { id: 'login', variant: 'error', message: error.value } : null));
Two limits of useI9kToastSource:
- It re-shows only when the getter's value changes. Setting the ref to the text it already holds does nothing, so a repeated identical failure is not shown again: clear the ref at the start of each attempt (error.value = null), then set it on failure.
- It re-shows whenever anything else the getter reads changes, e.g. a t() call when the locale switches — which brings back a toast the user dismissed. Keep the getter to the ref, or accept that.
Or raise one imperatively:
const toaster = useI9kToaster();
toaster.show({ variant: 'success', message: 'Changes saved.' }); // returns the toast's id
toaster.dismiss(id); toaster.clear();
A toast (I9kToastOptions) is plain text:
- message: string (required)
- variant?: 'info' | 'success' | 'warning' | 'error' (default 'info')
- id?: string — showing a toast with an id already on screen replaces it, restarts its timer and announces it again, so a repeated failure never piles up.
- detail?: string — a second line, e.g. the server's message; detailLang?: string and detailDir?: 'ltr' | 'rtl' | 'auto' mark its language, e.g. an Arabic server message on an English page.
- duration?: number | null — milliseconds before it dismisses itself. Default 5000 for info and success; warning and error never dismiss themselves (null = never; ask for a sticky toast with null — a value no timer can hold, such as Infinity or anything above 2147483647, is treated as null too).
Behavior:
- At most three toasts at once (createI9kToaster({ limit }) changes it); the oldest drops when another arrives. Newest is last.
- Timers pause while the pointer is over the stack or focus is inside it. Every toast has a dismiss button.
- Placement: top of the viewport, clear of the safe-area inset; the inline-end corner (top right in English, top left in Arabic) from 40rem up, full width below. It sits above I9kNavigation (z-index 300).
- Screen readers: two visually hidden live regions (role="status" for info, success and warning; role="alert" for errors) are present from the first render and hold the newest toast's text, but only in the toaster that was showing toasts when it was raised (in any toaster when none was mounted yet); the visible toasts carry no role, so nothing is announced twice — not even by a page toaster left in the accessibility tree beside a custom role="dialog" aria-modal="true" host. Focus is never moved to a toast.
- Modals: an open I9kModal renders its own I9kToaster inside the dialog when a store is provided, one layer deeper, and only the deepest host shows the toasts — so a toast raised while a modal is open is readable, announced and dismissable above the inert page. I9kSidebarLayout's open drawer (an aria-modal dialog on a narrow screen) does the same. Nothing to wire up. A result may be raised in the same tick the modal closes (open.value = false and toaster.show(), in either order): the page's toaster shows it and announces it once the dialog has closed, and a toast the modal already announced is never announced again by the page.
- SSR-safe: the state lives in the store created per app, never in a module singleton, and timers only start in the browser.
Props (I9kToaster):
- toaster?: I9kToasterStore | null — the store to show (the type createI9kToaster() returns, exported under that name); defaults to the installed one.
- label?: string — the stack's accessible name; defaults to the store's labels.region ('Notifications').
- dismissLabel?: string — each dismiss button's name; defaults to labels.dismiss ('Dismiss').
Both override this toaster only; a modal's toaster still reads the store's labels, so set translations on the store.
- size?: 'sm' | 'md' | 'lg' (default 'md')
IMPORTANT: do not also render the same message inline with I9kToast — the notification replaces the banner. I9kToast alone stays for standing status in the page flow.
I9kTooltip
A short plain-text note above an element, shown on hover, keyboard focus or tap. It also describes the element to assistive technology.
On a button
Up to 2 years in a real team; you ship tasks with someone reviewing them.
<I9kTooltip text="Up to 2 years in a real team; you ship tasks with someone reviewing them." v-slot="{ describedBy }">
<I9kButton :aria-describedby="describedBy">Junior</I9kButton>
</I9kTooltip>
Arabic
لحد سنتين في فريق حقيقي، وبتسلّم tasks بمراجعة.
<div dir="rtl" lang="ar">
<I9kTooltip text="لحد سنتين في فريق حقيقي، وبتسلّم tasks بمراجعة." v-slot="{ describedBy }">
<I9kButton :aria-describedby="describedBy">جونيور</I9kButton>
</I9kTooltip>
</div>
Watch out
The trigger must take focus, or a keyboard can never open it.
Bind `describedBy`, or a screen reader never hears the text.
The server render hides the bubble with the `hidden` attribute, not an inline style, so a strict style-src CSP cannot show it open before hydration; its placement is written through the CSSOM, which that CSP allows.
Inside `I9kSection variant="primary"` the bubble takes the section green, so its white text stays readable.
Props
Prop
Type
Default
text*
string
—
Slots: default
Agent prompt
Use I9kTooltip from @9klabs/design to explain an element in a sentence, such as who a course level is for.
import { I9kTooltip } from '@9klabs/design';
Props:
- text: string (required) — the tooltip's words; plain text only.
Emits: none.
Slots:
- default({ describedBy }) — the trigger. Bind describedBy to the trigger's aria-describedby, and make the trigger focusable (a button, a link, or tabindex="0").
Behavior: the bubble has role="tooltip" and is hidden by the hidden attribute until opened. It opens on mouse or pen hover and on keyboard focus, and closes when the pointer or focus leaves. A tap toggles it, because a finger has no hover; a tap elsewhere closes it. Escape closes it without moving the pointer or the focus, and the pointer can move onto the bubble without closing it (WCAG 1.4.13). It opens in the top layer (a manual popover), so no ancestor's overflow: hidden or transform clips it, placed above the trigger from its start edge in either reading direction, kept inside the viewport, below the trigger when there is no room above, and following it on scroll and resize. Where popovers are unsupported it falls back to sitting above the trigger inside it.
IMPORTANT: never put anything a reader must act on, or must read to use the page, only in a tooltip; it is hidden until asked for.
Usage:
<I9kTooltip text="Up to 2 years in a real team." v-slot="{ describedBy }">
<button type="button" :aria-describedby="describedBy">Junior</button>
</I9kTooltip>
I9kModal
Accessible modal dialog built on the native <dialog> element, labelled by its title, with a close button, a scrolling body, and an optional footer. The parent owns whether it is open.
Edit form
<I9kButton @click="open = true">Edit module</I9kButton>
<I9kModal v-model:open="open" title="Edit module" description="Both languages are required.">
<p>Module fields go here.</p>
<template #footer>
<I9kButton @click="open = false">Cancel</I9kButton>
<I9kButton variant="primary" @click="open = false">Save</I9kButton>
</template>
</I9kModal>
Watch out
The parent owns `open`: the close button and Escape only emit `update:open(false)`, so bind `v-model:open` or the modal never closes.
Backdrop clicks never close the modal.
Set `dismissible` to false while saving; Escape is then ignored and the close button is disabled.
The body and footer render only while open, so a form inside remounts on every opening; put `autofocus` on its first field.
The footer renders outside the body, so a submit button there needs `form="<id>"` pointing at the form in the body.
The `after` slot sits on the backdrop, not on the card: keep it to a short link, which reads on the dimmed page.
Props
Prop
Type
Default
open
boolean
false
title*
string
—
description
string
undefined
size
'sm' | 'md' | 'lg'
'md'
closeLabel
string
'Close'
dismissible
boolean
true
Emits
Event
Payload
update:open
[open: boolean]
close
[]
Slots: defaultfooterafter
Agent prompt
Use I9kModal from @9klabs/design for a focused task that must interrupt the page, such as an edit form.
import { I9kModal } from '@9klabs/design';
Props:
- open?: boolean (default false) — bind with v-model:open. The modal opens with showModal() when it becomes true and closes when it becomes false.
- title: string (required) — rendered as the <h2> that labels the dialog (aria-labelledby).
- description?: string — rendered under the title and wired to aria-describedby.
- size?: 'sm' | 'md' | 'lg' (default 'md') — the maximum width of the dialog.
- closeLabel?: string (default 'Close') — the accessible name of the icon-only close button; translate it.
- dismissible?: boolean (default true) — when false, Escape is ignored and the close button is disabled.
Emits:
- update:open(false) — the user asked to close (close button or Escape).
- close() — emitted together with update:open(false).
Slots:
- default — the dialog body; it scrolls when taller than the viewport.
- footer — actions such as Cancel and Save, aligned to the end.
- after — under the dialog's card, on the backdrop, centred: a way out of the dialog's task, such as "Forgot your password?" under a sign-in form. It is inside the <dialog>, so it is reachable by keyboard while the modal is open.
Behavior: the parent owns `open`. The close button and Escape only request a close by emitting update:open(false); the modal closes when the parent sets `open` to false, and focus returns to the element that was focused when it opened.
IMPORTANT: clicking the backdrop never closes the modal, so an unsaved form is not lost to a stray click.
IMPORTANT: set `dismissible` to false while a save is in flight so the user cannot close the modal halfway through.
IMPORTANT: the body and footer render only while the modal is open, so a form inside remounts on every opening and starts from fresh state. Put `autofocus` on the first field so focus lands there.
Usage:
<I9kButton @click="open = true">Edit module</I9kButton>
<I9kModal v-model:open="open" title="Edit module" description="Both languages are required." close-label="Close">
<form id="edit-module-form" @submit.prevent="save">
<I9kInput v-model="title" label="Title" autofocus />
</form>
<template #footer>
<I9kButton @click="open = false">Cancel</I9kButton>
<I9kButton variant="primary" type="submit" form="edit-module-form">Save</I9kButton>
</template>
</I9kModal>
Site chrome
I9kAnnouncementBar
Full-bleed brand-green bar for one short site-wide message, an optional link and a close button, laid out in the shared page column. Placed above I9kNavigation. Stateless: it emits close and the consumer hides it.
With a link
<I9kAnnouncementBar href="/changelog" link-label="Read the changelog" close-label="Close announcement">
Version 2 is out.
</I9kAnnouncementBar>
Without a link
<I9kAnnouncementBar close-label="Close announcement">
Scheduled maintenance on Sunday from 02:00 to 03:00 UTC.
</I9kAnnouncementBar>
Not dismissible (preview)
<I9kAnnouncementBar :dismissible="false" href="/changelog" link-label="Read the changelog">
Version 2 is out.
</I9kAnnouncementBar>
RTL
<div lang="ar" dir="rtl">
<I9kAnnouncementBar label="إعلان" href="/changelog" link-label="اقرأ سجل التغييرات" close-label="إغلاق الإعلان">
صدر الإصدار الثاني.
</I9kAnnouncementBar>
</div>
Watch out
Stateless: clicking close only emits `close`. The consumer removes the bar and remembers the choice; without that it stays on screen and comes back on every page.
The link renders only when both `href` and `linkLabel` are non-empty — one without the other renders the message alone.
Place it above I9kNavigation, as a sibling, never inside it. It is not sticky: it scrolls away and the navigation stays pinned.
Give `closeLabel` whenever the bar is dismissible: the close button is an icon, and an empty label leaves it with no accessible name.
Use `:dismissible="false"` for a preview of the bar (for example in an admin form), where closing it would mean nothing.
The fill is the brand green in both themes, and the bar re-maps the theme tokens its close button reads so the icon, hover and focus ring stay visible on it.
Props
Prop
Type
Default
href
string | null
null
linkLabel
string | null
null
linkComponent
string | object | null
null
closeLabel
string
''
label
string
'Announcement'
dismissible
boolean
true
Emits
Event
Payload
close
[]
Slots: default
Agent prompt
Use I9kAnnouncementBar from @9klabs/design to show one short site-wide announcement above the site header.
import { I9kAnnouncementBar } from '@9klabs/design';
Props:
- href?: string | null (default null) — the link's destination.
- linkLabel?: string | null (default null) — the link's text. The link renders only when both href and linkLabel are non-empty.
- linkComponent?: string | object | null (default null) — render the link with this component instead of a plain <a>, receiving the destination as `to` rather than `href`. Pass an imported component (RouterLink, NuxtLink), not its name as a string.
- closeLabel?: string (default '') — the close button's accessible name, such as "Close announcement". Required whenever the bar is dismissible.
- label?: string (default 'Announcement') — the accessible name of the bar's <aside> landmark. Translate it on a localized page.
- dismissible?: boolean (default true) — false renders no close button.
Emits: close — [], fired when the close button is clicked.
Slots: default — the message text. Keep it to one short sentence; the link follows it on the same line and wraps under it on a narrow screen.
IMPORTANT: the bar never hides itself. Listen for close, remove the bar (v-if), and remember the choice (for example in localStorage or a cookie, keyed by the announcement) so it stays closed on the next page.
Placement: render it before I9kNavigation, as its sibling — not inside it. The bar is not sticky, so it scrolls away while the sticky navigation stays. Its content sits in an I9kContainer, so the message starts where the navigation's brand does; do not wrap the bar in another container.
Usage:
<I9kAnnouncementBar
v-if="!closed"
href="/changelog"
link-label="Read the changelog"
close-label="Close announcement"
@close="closed = true"
>
Version 2 is out.
</I9kAnnouncementBar>
<I9kNavigation :links="links" />
I9kNavigation
Sticky site header with a brand slot, a link list, and an actions slot, laid out in the shared page column. Tracks scroll position to add a background on scroll and switch the brand into a compact state. Renders plain anchors, or a caller-supplied router link component.
Without `linkComponent` the brand and menu render plain `<a href>`, so in a router app every click is a full page load — pass the router link component (RouterLink, NuxtLink) as an imported component, not a string name.
The `links` prop is a plain array of `{ id, label, href }` objects — the menu is not built from slot content.
The `brand` and `actions` slots both receive a `compact: boolean` slot prop; read it to swap the logo/wordmark for a condensed version.
`compactAt` and `expandAt` are independent pixel thresholds with hysteresis between them, not one toggle point — a scroll position between the two keeps the current compact state.
Props
Prop
Type
Default
links*
I9kNavigationLink[]
—
brandHref
string
'/'
brandLabel
string
'Home'
compactAt
number
72
expandAt
number
24
linkComponent
string | object | null
null
Emits
Event
Payload
navigate
[link: I9kNavigationLink, event: MouseEvent]
Slots: brandactions
Agent prompt
Use I9kNavigation from @9klabs/design for a page's top-level site header.
import { I9kNavigation } from '@9klabs/design';
Props:
- links: I9kNavigationLink[] (required) — the nav menu. Each item is { id: string; label: string; href: string }. This type is not exported from the package; inline the object shape or declare your own local type.
- brandHref?: string (default '/') — the href on the brand link wrapping the `brand` slot.
- brandLabel?: string (default 'Home') — used as the accessible label for both the brand link and the surrounding <nav>.
- compactAt?: number (default 72) — scroll offset in pixels past which the header switches into its compact state.
- expandAt?: number (default 24) — scroll offset in pixels below which the header switches back to its expanded state.
- linkComponent?: string | object | null (default null) — render the brand and every menu link with this component instead of a plain <a>, receiving the destination as `to` rather than `href`. Pass an imported component (RouterLink, NuxtLink), not its name as a string: a string is resolved through resolveDynamicComponent, which does not see locally or compile-time registered components.
Emits: navigate — [link: I9kNavigationLink, event: MouseEvent], fired when a menu link is clicked (in addition to the link's normal navigation).
Slots:
- brand — the logo/wordmark content, wrapped in the brand link. Receives one slot prop: compact: boolean, true once the scroll position has passed compactAt.
- actions — content rendered after the menu (e.g. a sign-in button), not wrapped in a link. Also receives compact: boolean, so a mobile menu next to the brand can match whatever the brand is showing.
Active links: with a router link component the header styles the active route itself, from the `router-link-active` class and `aria-current="page"` that vue-router sets. Plain anchors get no active state.
Compact/expand behavior: compactAt and expandAt are both scroll-Y pixel thresholds, not a single toggle point. Scrolling past compactAt turns compact on; scrolling back below expandAt turns it off. Because expandAt is lower than compactAt by default, there is a dead zone between them where the current state is kept — this hysteresis stops the header from flickering when the scroll position hovers near one threshold. Set expandAt below compactAt when customizing either.
IMPORTANT: links is an array of plain objects, not slot content — build the menu by passing links, and use the brand and actions slots only for content outside that list.
Column: the bar's content renders inside an I9kContainer, so the brand starts where an I9kContainer section's content does and where I9kFooter's columns do. Do not wrap the header in another container. An area that uses the wide column throughout redefines --container-width-md on its root and the header follows.
Usage:
<I9kNavigation :links="[{ id: 'docs', label: 'Docs', href: '/docs' }, { id: 'pricing', label: 'Pricing', href: '/pricing' }]" @navigate="onNavigate">
<template #brand="{ compact }"><span :class="{ compact }">Acme</span></template>
<template #actions><I9kButton variant="primary" href="/signup">Sign up</I9kButton></template>
</I9kNavigation>
I9kNavMenu
Mobile navigation: a bars toggle that opens a full-screen panel with the brand, the link list, and a footer slot. Owns the modal behavior — teleport, scroll lock, focus trap, Escape, and closing itself when the viewport passes the desktop breakpoint.
The component renders at every width — it has no breakpoint of its own. Hide it above the breakpoint from the caller, and keep `desktopQuery` one pixel above that width so a resize closes an open panel.
The panel is teleported to `<body>`, so no CSS scoped to the header that mounts this component can reach it; style the panel through the `brand` and `footer` slots instead.
Without `linkComponent` the menu renders plain `<a href>`, so in a router app every click is a full page load — pass the router link component (RouterLink, NuxtLink) as an imported component, not a string name.
The panel closes itself on a link click, but it cannot see a navigation started from `footer` slot content (a language switcher, a call to action) or from the browser back button — bind `v-model:open` and close it from the consumer for those.
`preview` is for specimen pages only: it strips the teleport, the focus trap, the scroll lock, and both buttons, leaving a bounded box that documents the composition.
Props
Prop
Type
Default
links*
I9kNavigationLink[]
—
open
boolean
false
compact
boolean
false
linkComponent
string | object | null
null
preview
boolean
false
openLabel
string
'Open menu'
closeLabel
string
'Close menu'
menuLabel
string
'Site menu'
desktopQuery
string
'(min-width: 769px)'
Emits
Event
Payload
update:open
[open: boolean]
navigate
[link: I9kNavigationLink, event: MouseEvent]
Slots: brandfooter
Agent prompt
Use I9kNavMenu from @9klabs/design for the small-screen counterpart to I9kNavigation, whose own menu hides at 768px.
import { I9kNavMenu } from '@9klabs/design';
Props:
- links: I9kNavigationLink[] (required) — the same { id: string; label: string; href: string } shape I9kNavigation takes, so both can be fed from one array. The type is not exported from the package; inline the object shape or declare your own local type.
- open?: boolean (default false) — optional external control. The panel drives itself, so this can be left unbound; bind it with v-model:open when something outside the panel has to close it, such as a router navigation the panel cannot see.
- compact?: boolean (default false) — forwarded to the `brand` slot, so the wordmark in the panel head can match whatever the header was showing when the toggle was tapped.
- linkComponent?: string | object | null (default null) — render the menu links with this component instead of a plain <a>, receiving the destination as `to` rather than `href`. Pass an imported component (RouterLink, NuxtLink), not its name as a string: a string is resolved through resolveDynamicComponent, which does not see locally or compile-time registered components.
- openLabel?: string (default 'Open menu') / closeLabel?: string (default 'Close menu') — accessible names for the toggle and the close button. closeLabel is also the toggle's name while the panel is open.
- menuLabel?: string (default 'Site menu') — accessible name for the panel dialog.
- desktopQuery?: string (default '(min-width: 769px)') — media query that closes the panel when the viewport grows past the breakpoint. Keep it one pixel above the width at which the caller stops showing this component.
- preview?: boolean (default false) — documentation mode. Renders the panel open and in normal flow: no teleport, no fixed positioning, no scroll lock, no focus trap, no toggle, no close button. For specimen pages only; never use it in a real header.
Emits:
- navigate — [link: I9kNavigationLink, event: MouseEvent], fired when a menu link is clicked, just before the panel closes itself.
- update:open — [open: boolean], fired on every open and close, including Escape and the desktop-breakpoint close.
Slots:
- brand — the logo/wordmark in the panel head. Receives one slot prop: compact: boolean, mirroring the compact prop.
- footer — the block pinned to the bottom of the panel, above the safe-area padding: theme and language controls, a primary call to action. Rendered in a grid; supply the rows.
Active links: with a router link component the panel styles the active route itself, from the `router-link-active` class and `aria-current="page"` that vue-router sets — the active link takes the primary color and reveals its trailing caret.
IMPORTANT: this component only renders the toggle and the panel — it has no breakpoint of its own. Hide it above the breakpoint from the caller, the way I9kNavigation hides its own menu below it.
Usage:
<I9kNavMenu :links="links" :link-component="NuxtLink" :compact="compact" @navigate="onNavigate">
<template #brand="{ compact }"><I9kBrandWordmark :compact="compact" /></template>
<template #footer><I9kButton variant="primary" href="/contact">Work with me</I9kButton></template>
</I9kNavMenu>
I9kProfileMenu
The signed-in account's control for a navigation bar: an avatar button that opens a small panel naming the account, its links and the caller's actions, such as signing out.
Pass `linkComponent` as an imported component (RouterLink, NuxtLink), not a string name; without it every link is a plain `<a href>` and a full page load.
The panel closes itself on a link click, but not on a router navigation it did not start (the Back button, a link elsewhere on the page) — bind `v-model:open` and close it from the consumer on every route change.
The avatar on the button is decorative (`aria-hidden`), so `label` is the whole accessible name of the button; make it name the control ("Account menu"), not the picture.
The panel is aligned to the inline end of the button and opens inward, so place the menu at the end of a bar (the right in left-to-right pages, the left in right-to-left ones).
The panel is not teleported, so a bar with `overflow: hidden` would clip it; I9kNavigation does not.
The closed panel is hidden with the `hidden` attribute, never an inline `style`, so it works under a `style-src` Content-Security-Policy without `'unsafe-inline'`; do not restyle `.i9k-profile-menu__panel` with a `display` that beats `[hidden]`.
Props
Prop
Type
Default
label*
string
—
name
string | null
null
detail
string | null
null
avatarSrc
string | null
null
links
I9kNavigationLink[]
() => []
linkComponent
string | object | null
null
open
boolean
false
size
'sm' | 'md' | 'lg'
'md'
Emits
Event
Payload
update:open
[open: boolean]
navigate
[link: I9kNavigationLink, event: MouseEvent]
Slots: actions
Agent prompt
Use I9kProfileMenu from @9klabs/design for the signed-in account's control in a navigation bar.
import { I9kProfileMenu } from '@9klabs/design';
Props:
- label: string (required) — the button's accessible name, for example "Account menu". The avatar inside the button is decorative, so this is the button's whole name.
- name?: string | null (default null) — the account's display name, shown at the top of the panel. When it is null only the detail line shows.
- detail?: string | null (default null) — a second line under the name, such as the account's email address.
- avatarSrc?: string | null (default null) — any image URL for the account's picture, including a data: URI; shown on the button and in the panel.
- links?: I9kNavigationLink[] (default []) — the { id: string; label: string; href: string } shape I9kNavigation takes, listed in order in the panel. The type is not exported from the package; inline the object shape or declare your own local type.
- linkComponent?: string | object | null (default null) — render the links with this component instead of a plain <a>, receiving the destination as `to` rather than `href`. Pass an imported component (RouterLink, NuxtLink), not its name as a string.
- open?: boolean (default false) — optional external control, bound with v-model:open. The panel drives itself, so this can be left unbound.
- size?: 'sm' | 'md' | 'lg' (default 'md') — the size of the avatar on the button, on the shared control scale.
Emits:
- update:open — [open: boolean], fired on every open and close.
- navigate — [link: I9kNavigationLink, event: MouseEvent], fired when a panel link is clicked, just before the panel closes itself.
Slots:
- actions — the block at the bottom of the panel, such as a sign-out button. Receives one slot prop, close: () => void, which closes the panel and returns focus to the button.
Behavior: a disclosure, not an ARIA menu (no role="menu"). The button carries aria-expanded and aria-controls, and the panel follows it in DOM order, so Tab walks from the button into the links and actions. Escape closes the panel and returns focus to the button; it is heard on the whole document while the panel is open, because Safari and Firefox on macOS do not focus a clicked button. A press outside the menu, keyboard focus leaving it, focus moving into an iframe (a video player), following a link, or calling the actions slot's close closes it. The name and detail lines take dir="auto", so a Latin name or an email address keeps its order on a right-to-left page. The panel is not teleported: it opens under the button, inside the bar's own stacking context (z-index 150 inside a bar at 100). A closed panel is hidden with the `hidden` attribute, not an inline style, so it server-renders without one and stays closed before hydration under a strict style-src Content-Security-Policy.
Usage:
<I9kProfileMenu
v-model:open="accountOpen"
label="Account menu"
:name="user.name"
:detail="user.email"
:avatar-src="user.image"
:links="accountLinks"
:link-component="NuxtLink"
>
<template #actions="{ close }">
<I9kButton @click="close(); signOut()">Sign out</I9kButton>
</template>
</I9kProfileMenu>
I9kBreadcrumb
A breadcrumb trail in a named navigation landmark: every step but the last links back, and the last is the current page.
The last item is always the current page, even when it has an href; put the page you are on last.
Without `linkComponent` the steps are plain `<a href>`, so in a router app every click is a full page load.
Pass `label` in the page language; the default is English.
Props
Prop
Type
Default
items*
I9kBreadcrumbItem[]
—
label
string
'Breadcrumb'
linkComponent
string | object | null
null
Agent prompt
Use I9kBreadcrumb from @9klabs/design to show where the current page sits, for example course / lesson in a slim top bar.
import { I9kBreadcrumb, type I9kBreadcrumbItem } from '@9klabs/design';
Props:
- items: I9kBreadcrumbItem[] (required) — { id: string; label: string; href?: string }, outermost first. Every item but the last renders as a link when it has an href. The last item is always the current page: a <span aria-current="page">, never a link.
- label?: string (default 'Breadcrumb') — the navigation landmark's accessible name. Pass it in the page's language.
- linkComponent?: string | object | null (default null) — render the links with this component (RouterLink, NuxtLink), which receives the destination as `to`. Pass the imported component, not its name.
Behavior: separators are aria-hidden. Long labels truncate with an ellipsis and keep the full text in a title.
Usage:
<I9kBreadcrumb
label="Breadcrumb"
:link-component="NuxtLink"
:items="[{ id: 'course', label: 'Leveraging AI', href: '/courses/ai' }, { id: 'lesson', label: 'Hello, Agent' }]"
/>
I9kSidebarLayout
A full-height frame: a slim sticky bar, a sidebar and a main area. Wide screens show the sidebar as a column the reader can hide; narrow screens open it as a modal drawer from the bar.
The breakpoint is fixed at 769px and is not a prop: the stylesheet has to hide the drawer before hydration, and a media query cannot read a prop.
It renders its own `<header>`, `<aside>`, `<main>` and `<footer>`; use it as the whole page, not inside another shell.
The sidebar follows the main content in the DOM, so keyboard and screen-reader users reach the page first; do not reorder it with CSS order in a wrapper.
Before mount it assumes a wide screen for layout, and the toggle carries no aria-expanded until the client has read the real width; the stylesheet keeps the drawer hidden on a narrow screen meanwhile.
Set `--i9k-sidebar-layout-height` on a parent to bound the frame (for a demo); it defaults to `100dvh`.
With a toaster store installed, the open drawer hosts its own `I9kToaster`, so do not place one in its slots yourself; the page’s own `<I9kToaster />` belongs at the app root, outside the layout.
Use I9kSidebarLayout from @9klabs/design for a page that is its own frame — a course player, a documentation reader — with navigation in a sidebar beside the content.
import { I9kSidebarLayout } from '@9klabs/design';
Props:
- sidebarLabel: string (required) — accessible name of the sidebar landmark, and of the drawer dialog on a narrow screen.
- toggleLabel?: string (default 'Toggle sidebar') — the bar toggle's accessible name. It stays the same in both states; aria-expanded carries the state.
- closeLabel?: string (default 'Close sidebar') — the drawer's close button.
- sidebarHidden?: boolean (default false) — wide screens only. Bind v-model:sidebar-hidden to keep the choice across remounts (e.g. in app state).
- drawerOpen?: boolean (default false) — narrow screens only. Bind v-model:drawer-open to close the drawer from outside.
Emits:
- update:sidebarHidden — [hidden: boolean]
- update:drawerOpen — [open: boolean], including Escape, the backdrop, a followed link and widening past the breakpoint.
Slots:
- bar — after the toggle: brand, breadcrumb (see I9kBreadcrumb).
- bar-end — pushed to the inline end: theme switcher, account actions.
- sidebar — the scrolling navigation. Without it there is no sidebar and no toggle.
- sidebar-footer — pinned at the sidebar's foot: a title, a progress bar.
- default — the page content, inside <main>.
- footer — optional <footer> after main, e.g. legal links.
Behavior: the breakpoint is fixed at 769px (the same as I9kNavigation). Wide: the toggle hides or shows the sidebar column, which is sticky and exactly as tall as the viewport below the bar. The page content comes before the sidebar in reading order, so keyboard and screen-reader users reach it first; on a wide screen the grid shows the sidebar at the inline start. Narrow: the toggle opens a modal drawer (role=dialog, aria-modal) with a close button that takes focus; the bar and main area become inert and the page stops scrolling; Escape, the close button, the backdrop and following a link inside close it, and focus returns to the toggle, except after a link to another page (a link marked aria-current="page" goes nowhere, so it returns focus too). On mount and whenever the drawer opens, an element with aria-current="page" inside the sidebar is scrolled into the middle of the sidebar (the sidebar scrolls, never the window). The layout renders <header>, <aside>, <main> and <footer> itself, so do not wrap it in another <main>. With an I9kToaster store installed (app.use(createI9kToaster())), the open drawer renders its own toaster inside the dialog, as I9kModal does, so a notification raised from the drawer (a failed action in the sidebar footer) is shown, announced and dismissable there rather than behind the inert page; it hands them back to the page's toaster when the drawer closes. Nothing to wire up.
Usage:
<I9kSidebarLayout sidebar-label="Course contents" toggle-label="Course contents" close-label="Close course contents">
<template #bar><I9kBreadcrumb :items="crumbs" /></template>
<template #bar-end><I9kThemeSwitcher v-model="isDark" /></template>
<template #sidebar><nav>…lessons, one with aria-current="page"…</nav></template>
<template #sidebar-footer>Course progress</template>
<p>Lesson</p>
</I9kSidebarLayout>
I9kSideNav
An app sidebar's section links: a labelled navigation landmark holding a vertical list, with the current section marked, and a footer slot below the list.
Nothing is current unless a link says so: the component never compares hrefs with the location.
The caller sets `current`, so mark the section, not only the exact page. A router link component may additionally set aria-current on its own exact match.
Text only: there is no icon slot.
Props
Prop
Type
Default
label*
string
—
links*
I9kSideNavLink[]
—
linkComponent
string | object | null
null
Slots: footer
Agent prompt
Use I9kSideNav from @9klabs/design for the section links in an app's sidebar, inside I9kSidebarLayout's `sidebar` slot.
import { I9kSideNav, type I9kSideNavLink } from '@9klabs/design';
Props:
- label: string (required) — the navigation landmark's accessible name, such as "Admin sections".
- links: I9kSideNavLink[] (required) — { id: string; label: string; href: string; current?: boolean }. Set current on the section the reader is in; that link renders aria-current="page".
- linkComponent?: string | object | null (default null) — render the links with this component instead of a plain <a>, receiving the destination as `to`. Pass an imported component (RouterLink, NuxtLink), not its name as a string.
Slots:
- footer — below the list, inside the landmark: a secondary link such as "Browse the courses".
The component does not read the router. Decide `current` from the route in the caller, so a nested page (a course's editor under Courses) can mark its section.
Usage:
<I9kSidebarLayout sidebar-label="Admin sections">
<template #sidebar>
<I9kSideNav label="Admin sections" :links="links" :link-component="NuxtLink" />
</template>
...
</I9kSidebarLayout>
I9kFooter
A page's bottom chrome. On its own it is a centred row of social links and a tagline; given link columns or a brand it becomes a multi-column site footer with a bottom bar.
The layout switches on `columns.length` or a filled `brand` slot — pass neither and you get the centred stack, even if you set `copyright` (which only renders in the multi-column brand block).
Only site-relative hrefs (a single leading "/") are handed to `linkComponent`; `https:`, `mailto:`, `tel:` and protocol-relative `//host` links always render plain anchors.
`external: true` adds `target="_blank"`, `rel="noopener"` and an aria-hidden arrow. Do not set it on `mailto:` links.
The default slot fully replaces the tagline paragraph rather than appending to it.
The social row only renders when `socialLinks` is non-empty. I9kSocialLink is not exported — inline its shape.
Both layouts render their content inside an I9kContainer, so the footer lines up with the header and the page column on its own; do not wrap it in another container.
Decorative, self-animating logotype that types itself out and swaps between a full and short form — purely visual, and carries no accessible name of its own.
It renders `aria-hidden="true"` on its root — always wrap it in an element that supplies a real accessible name (e.g. an `<a aria-label="...">`), it is purely decorative on its own.
It animates on an internal 15–35 second random timer (an idle "wink"), and retypes between `full`/`short` when `compact` changes — there is no prop or event to trigger this manually, so do not build a demo or test that waits for it to visibly happen.
Both the retype-on-`compact`-change animation and the idle wink are skipped when the visitor has `prefers-reduced-motion` set; the text just settles directly to its target.
Props
Prop
Type
Default
compact
boolean
false
full
string
'Ismail9k'
short
string
'9k'
Agent prompt
Use I9kBrandWordmark from @9klabs/design for a site's animated logotype, typically inside I9kNavigation's `brand` slot bound to that slot's `compact` prop.
import { I9kBrandWordmark } from '@9klabs/design';
Props (this is the one component in the package using the runtime `defineProps({...})` form rather than the type-generic form, but the resolved props are the same shape):
- compact?: boolean (default false) — when true, the wordmark types itself down to `short`; when false, it types back up to `full`.
- full?: string (default 'Ismail9k') — the expanded text.
- short?: string (default '9k') — the condensed text.
Emits: none. Slots: none.
IMPORTANT: the whole component renders with `aria-hidden="true"` — it has no accessible name of its own. Wrap it in an element that supplies one, e.g. an `<a aria-label="Ismail9k, back to homepage">`, rather than relying on the wordmark's visible text to be read by assistive tech.
IMPORTANT: do not try to demo or screenshot the animation — it is driven entirely internally. Changing `compact` retypes the text via a timed animation; separately, on an internal 15–35 second random timer, a compact, resting wordmark occasionally "winks" into a random face (e.g. '^_^') for two seconds before typing back to `short`. There is no prop or event to trigger, control, or observe the wink — it is purely ambient and non-deterministic, and (like the retype) is skipped entirely when the user has `prefers-reduced-motion` set.
Usage:
<a href="/" aria-label="Ismail9k, back to homepage">
<I9kBrandWordmark :compact="isScrolledPastHeader" />
</a>
I9kSocialLinks
Row of pill-shaped social/contact links, each opening in a new tab, with an icon fallback and a customizable accessible label per item.
Every link opens in a new tab (`target="_blank" rel="noopener"`) — this is not configurable per item or overall.
An item with no `icon` falls back to a single-letter avatar (the first character of `name`), not a blank space — supply `icon` for every item that should show a real icon.
`followLabel` builds the accessible name from `item.name`; override it when the links are not "follow" actions (e.g. email or phone contact links).
Props
Prop
Type
Default
items*
I9kSocialLink[]
—
labels
boolean
false
followLabel
(platform: string) => string
(platform: string) => `Follow on ${platform}`
Emits
Event
Payload
click
[item: I9kSocialLink, event: MouseEvent]
Slots: icon
Agent prompt
Use I9kSocialLinks from @9klabs/design for a row of social or contact links (also used internally by I9kFooter).
import { I9kSocialLinks } from '@9klabs/design';
Props:
- items: I9kSocialLink[] (required) — { name: string; url: string; label?: string; icon?: I9kIconName }. This type is not exported from the package — inline the shape or declare your own local type.
- labels?: boolean (default false) — shows `item.label ?? item.name` as visible text next to each icon.
- followLabel?: (platform: string) => string (default `(platform) => \`Follow on \${platform}\``) — builds each link's `aria-label` from `item.name`. Override it for non-social contexts, e.g. a plain "Email" or "Call" link.
Emits: click — [item: I9kSocialLink, event: MouseEvent].
Slots:
- icon — one slot prop, item: I9kSocialLink. Replaces the default rendering (an I9kIcon when `item.icon` is set, otherwise the first letter of `item.name`) for every link.
IMPORTANT: every link renders with `target="_blank" rel="noopener"` unconditionally — there is no prop to open a link in the same tab.
Usage:
<I9kSocialLinks
:items="[
{ name: 'GitHub', url: 'https://github.com/ismail9k', icon: 'github' },
{ name: 'Mail', url: 'mailto:[email protected]', icon: 'mail' },
]"
@click="onSocialClick"
/>
I9kThemeSwitcher
Controlled light/dark icon button. It renders and emits the toggle only — applying the theme to the page is entirely the caller's job.
Controlled toggle (local state only — does not change this page's theme)
It is a controlled component with no side effects of its own — it never touches `document.documentElement` or any global theme state; you must apply the theme yourself in response to `update:modelValue`.
`modelValue` is the single source of truth for which glyph and label are shown — there is no internal toggle state, so it will not visually flip unless you update `modelValue` from the emitted event.
The glyph names the theme the button switches TO, not the one that is active: a moon while the page is light, a sun while it is dark. This matches `darkLabel`/`lightLabel`, so keep those labels phrased as actions.
It renders a square icon button sized to `--control-height-sm`, matching `I9kLanguageSwitcher` in its icon form so the two sit together in a header.
Props
Prop
Type
Default
modelValue
boolean
false
lightLabel
string
'Switch to light mode'
darkLabel
string
'Switch to dark mode'
Emits
Event
Payload
update:modelValue
[value: boolean]
Agent prompt
Use I9kThemeSwitcher from @9klabs/design as the visual control for a light/dark theme toggle.
import { I9kThemeSwitcher } from '@9klabs/design';
Props:
- modelValue?: boolean (default false) — true means dark mode is active; the switch is fully controlled by this prop, it holds no state of its own.
- lightLabel?: string (default 'Switch to light mode') — the accessible label used while `modelValue` is true (i.e. the action the next click performs).
- darkLabel?: string (default 'Switch to dark mode') — the accessible label used while `modelValue` is false.
Emits: update:modelValue — [value: boolean], fired on click with the flipped value. Use `v-model` to wire it up.
Slots: none.
IMPORTANT: this component does NOT touch `document.documentElement` or any global theme state itself — it only renders a button and emits the new value. You are responsible for reacting to `update:modelValue` (or a `v-model` watcher) to actually apply the theme, e.g. `document.documentElement.classList.toggle('dark', isDark)`, and for persisting the choice if needed.
Usage:
<I9kThemeSwitcher v-model="isDark" @update:model-value="applyTheme" />
I9kLanguageSwitcher
A link to an alternate-language version of the current page, shown as a compact glyph-plus-locale-code button. It is only the link's markup — it does not itself switch the page's language or navigate via JS.
It never touches `document.documentElement.lang` or `dir`, and does not navigate via JavaScript — it is a plain link; the language actually changes only when the browser follows `href` (or your `linkComponent` routing does).
`code` is uppercased for display, which is a no-op for non-latin scripts — pass `code="ar"` to get "AR", or `code="ع"` if you want the target language in its own script.
Without `code` the control keeps its older full-text form, showing `label` inline. That fallback exists so consumers on an older build keep working; new callers should pass `code`.
`label` is still required in both forms — with `code` it is the accessible name, without it the visible text. Default slot content replaces the visible content but never the `aria-label`.
Props
Prop
Type
Default
label*
string
—
href*
string
—
code
string | null
null
hreflang
string | null
null
linkComponent
string | object | null
null
Slots: default
Agent prompt
Use I9kLanguageSwitcher from @9klabs/design for a link to an alternate-language version of the current page.
import { I9kLanguageSwitcher } from '@9klabs/design';
Props:
- label: string (required) — the human-readable name of the target language, e.g. 'العربية'. With `code` set this becomes the link's `aria-label`; without `code` it is the visible fallback text.
- href: string (required) — the destination URL, typically the same page in the other language.
- code?: string | null (default null) — the target locale code, e.g. 'ar'. When set, the control renders a translate glyph plus the code uppercased ('AR'), and `label` moves to `aria-label`. Pass this for the compact header form.
- hreflang?: string | null (default null) — sets the anchor's `hreflang` attribute, e.g. 'ar'.
- linkComponent?: string | object | null (default null) — a component to render instead of `<a>`, receiving `to` set to `href`, e.g. a router link component.
Emits: none. Slots: default — replaces the visible content entirely; falls back to the glyph-plus-code (or `{{ label }}` when `code` is unset).
IMPORTANT: prefer passing `code` — 'AR'/'EN' beside the glyph keeps the control the same height as I9kThemeSwitcher so the two pair up in a header. Omitting `code` falls back to the older full-text pill form.
IMPORTANT: this component does not change `document.documentElement.lang`/`dir`, and does not navigate via JavaScript — it renders a plain `<a href>` (or your `linkComponent`). Actually switching the visitor's language happens through normal navigation to `href`, or through your own routing logic if you pass `linkComponent`.
Usage:
<I9kLanguageSwitcher label="العربية" code="ar" href="/ar" hreflang="ar" />
I9kBlurredCircles
Decorative, non-interactive layer of four slow-drifting blurred circles, meant as an ambient page background sitting behind real content.
Confined to a container (transform + overflow: hidden contain the fixed layer)
Real page content stacks above this ambient background layer.
It is `position: fixed` and covers the entire browser viewport by default, regardless of where in the DOM it is mounted or how its parent is sized.
To confine it to one container (as in this demo) instead of the whole page, give an ancestor `transform`/`filter`/`contain: paint` (any of these creates a new containing block for `position: fixed` descendants) plus `overflow: hidden`.
It has no `z-index` of its own — it stays behind your content only via DOM order (mount it before your other content) or your own stacking context.
Agent prompt
Use I9kBlurredCircles from @9klabs/design as a decorative ambient background layer, mounted once near the root of a page, behind your real content.
import { I9kBlurredCircles } from '@9klabs/design';
Props: none. Emits: none. Slots: none — it renders its own fixed set of four circles and nothing else.
Behavior: it renders `position: fixed; inset: 0; pointer-events: none;` with `aria-hidden="true"` — by default it covers the ENTIRE VIEWPORT, not just its parent element, sits behind content only because of normal DOM/stacking order (it has no `z-index`), and never intercepts clicks. The four circles drift slowly via CSS animation, which is disabled under `prefers-reduced-motion`.
IMPORTANT: because it is `position: fixed`, mounting it anywhere covers the whole browser viewport by default — it does NOT stay confined to a parent container just because that parent is sized or positioned. To scope it to one section instead of the whole page, wrap it in an ancestor that establishes a new containing block for fixed-position descendants, e.g. one with `transform: translateZ(0)` (or any non-none `transform`/`filter`/`contain: paint`) plus `overflow: hidden` to clip it to that ancestor's bounds.
IMPORTANT: mount it once per page (typically as the first child inside your root layout), not once per section — several instances stack multiple full-viewport layers on top of each other.
Usage:
<body>
<I9kBlurredCircles />
<!-- rest of the page, stacked above it by DOM order -->
</body>
I9kGlow
Decorative, non-interactive page background: one large, soft, film-grained glow in the brand green, amber, and orange, half off-screen at an edge or corner, drifting slowly.
Default: centred on the inline end edge
Real page content stacks above this ambient background layer.
Use I9kGlow from @9klabs/design as a decorative ambient background layer, mounted once near the root of a page, behind your real content. It is the single-glow alternative to I9kBlurredCircles; use one or the other, not both.
import { I9kGlow } from '@9klabs/design';
Props:
- position?: 'top' | 'top-start' | 'top-end' | 'start' | 'end' | 'center' (default 'end') — where the glow sits. Every value except 'center' centres it on that edge or corner, so half of it is off-screen. 'start' and 'end' are logical: they follow the reading direction, so 'end' is the right edge in English and the left edge in Arabic. An unknown value falls back to 'end'.
Emits: none. Slots: none.
Behavior: it renders `position: fixed; inset: 0; pointer-events: none;` with `aria-hidden="true"` — it covers the ENTIRE VIEWPORT, not just its parent element, sits behind content only because of normal DOM/stacking order (it has no `z-index`), and never intercepts clicks. The glow drifts slowly via CSS animation, which is disabled under `prefers-reduced-motion`. Under an ancestor with the `dark` class it switches to a dimmer, green-led palette on its own.
IMPORTANT: because it is `position: fixed`, mounting it anywhere covers the whole browser viewport — it does NOT stay confined to a parent container. To scope it to one section, wrap it in an ancestor with `transform: translateZ(0)` (or any non-none `transform`/`filter`/`contain: paint`) plus `overflow: hidden`.
IMPORTANT: its colours use CSS relative colour syntax (`oklch(from …)`). A browser without it (roughly Chrome before 119, Safari before 18, Firefox before 128) shows no glow at all rather than a broken one.
Usage:
<body>
<I9kGlow position="top-end" />
<!-- rest of the page, stacked above it by DOM order -->
</body>
Rules for agents
These are the constraints a props table cannot express. They hold for every component above.
I9kInput names its visual scale prop `uiSize`, not `size`, so the native HTML `size` attribute stays available on the underlying input.
Every component owns its appearance in `<style scoped>` and must not rely on global classes for its look.
Components declare component-local custom properties on their root class and redefine them per size modifier, rather than consuming raw brand tokens for sizing.
Sizes and tones come from the shared `I9kComponentSize` and `I9kTone` types. Do not redeclare those string unions per component.
I9kButton renders a `<button>`, an `<a>`, or a caller-supplied component: pass `to` or `href` for a link, and `link-component="RouterLink"` in Vue Router apps.
I9kIcon renders from the local `src/icons/paths.json` set. Add new icons to that file rather than inlining SVG in a component.
Components emit legacy classes alongside their `i9k-` ones while the website migration is in progress. Do not remove a legacy selector or prop until its migration ledger row is complete.
Import the stylesheet once, at the application entry: `@9klabs/design/style.css`. It is the only CSS a consumer needs.
Any visual change needs checking in light and dark themes and in both LTR and RTL directions.