9k Design System

Every one of the 33 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

  • --primary-color
  • --on-primary-color
  • --accent-color
  • --on-accent-color
  • --theme-bg-color
  • --theme-text-color
  • --text-color-light
  • --border-color
  • --code-bg

Spacing

  • --spacing-1
  • --spacing-2
  • --spacing-3
  • --spacing-4
  • --spacing-6
  • --spacing-8
  • --spacing-10
  • --spacing-13
  • --spacing-18

Radius

  • --radius-sm
  • --radius-md
  • --radius-lg
  • --radius-pill

Control scale

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

Layout & surfaces

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.

Columns

Two
columns
Three
equal
columns
Auto
fill
<I9kGrid :columns="2"><I9kPanel size="sm">Two</I9kPanel><I9kPanel size="sm">columns</I9kPanel></I9kGrid>
<I9kGrid :columns="3"><I9kPanel size="sm">Three</I9kPanel><I9kPanel size="sm">equal</I9kPanel><I9kPanel size="sm">columns</I9kPanel></I9kGrid>
<I9kGrid columns="auto"><I9kPanel size="sm">Auto</I9kPanel><I9kPanel size="sm">fill</I9kPanel></I9kGrid>

Sizes

Small
gap
Large
gap
<I9kGrid :columns="2" size="sm"><I9kPanel size="sm">Small</I9kPanel><I9kPanel size="sm">gap</I9kPanel></I9kGrid>
<I9kGrid :columns="2" size="lg"><I9kPanel size="sm">Large</I9kPanel><I9kPanel size="sm">gap</I9kPanel></I9kGrid>

Watch out

  • `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
PropTypeDefault
asstring | Component'div'
columns1 | 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. Use it once per page as the outermost content wrapper.

Default

A centered 1000px page container with responsive gutters.

Page content
<I9kPageContainer style="min-height: 12rem; outline: 1px dashed var(--border-color)">
  <I9kText variant="lede">A centered 1000px page container with responsive gutters.</I9kText>
  <I9kPanel size="sm">Page content</I9kPanel>
</I9kPageContainer>

Sizes

Small gutter
Large gutter
<div style="display: grid; gap: var(--spacing-8)">
  <I9kPageContainer size="sm" style="min-height: 6rem; outline: 1px dashed var(--border-color)"><I9kPanel size="sm">Small gutter</I9kPanel></I9kPageContainer>
  <I9kPageContainer size="lg" style="min-height: 6rem; outline: 1px dashed var(--border-color)"><I9kPanel size="sm">Large gutter</I9kPanel></I9kPageContainer>
</div>

Watch out

  • `size` only sets the horizontal gutter (padding-inline) — the 1000px max width is fixed 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.
Props
PropTypeDefault
asstring | 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, max 1000px wide, 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 1000px max width or add vertical spacing between children. Wrap groups of children yourself (e.g. in I9kCluster or a styled div) if they need gaps.

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.

Default

Status
<I9kCluster>
  <I9kButton>Primary action</I9kButton>
  <I9kButton variant="link">Secondary action</I9kButton>
  <I9kBadge variant="outline">Status</I9kBadge>
</I9kCluster>

Sizes

SmallCluster
LargeCluster
<div style="display: grid; gap: var(--spacing-8)">
  <I9kCluster size="sm"><I9kBadge>Small</I9kBadge><I9kBadge>Cluster</I9kBadge></I9kCluster>
  <I9kCluster size="lg"><I9kBadge>Large</I9kBadge><I9kBadge>Cluster</I9kBadge></I9kCluster>
</div>

Wrapping

Design systemsVueAccessibilityRTL
<I9kCluster style="max-width: 16rem">
  <I9kBadge variant="tag">Design systems</I9kBadge>
  <I9kBadge variant="tag">Vue</I9kBadge>
  <I9kBadge variant="tag">Accessibility</I9kBadge>
  <I9kBadge variant="tag">RTL</I9kBadge>
</I9kCluster>

Watch out

  • `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
PropTypeDefault
asstring | 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

Bordered, blurred glass surface for grouping content. Use it as the standard card/surface wrapper wherever content needs visual separation from the page background.

Variants

Default panel
Feature panel
Flat panel
<div style="display: grid; gap: var(--spacing-8); grid-template-columns: repeat(3, minmax(0, 1fr))">
  <I9kPanel variant="default">Default panel</I9kPanel>
  <I9kPanel variant="feature">Feature panel</I9kPanel>
  <I9kPanel variant="flat">Flat panel</I9kPanel>
</div>

Sizes

Small panel
Medium panel
Large panel
<div style="display: grid; gap: var(--spacing-8)">
  <I9kPanel size="sm">Small panel</I9kPanel>
  <I9kPanel size="md">Medium panel</I9kPanel>
  <I9kPanel size="lg">Large panel</I9kPanel>
</div>

Watch out

  • `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
PropTypeDefault
asstring | 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, glass background, and backdrop blur. 'feature' emphasizes the border and background with an accent-tinted gradient 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
PropTypeDefault
asstring | 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 only, custom level

Recent projects

<I9kSectionHeading title="Recent projects" :level="3" />

Watch out

  • 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
PropTypeDefault
title*string
descriptionstring | nullnull
idstring | nullnull
level2 | 3 | 4 | 5 | 62

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

<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

<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
PropTypeDefault
title*string
descriptionstring | nullnull
idstring | nullnull
level1 | 2 | 3 | 4 | 5 | 61

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

Are AI coding tools ready to replace programmers? article header
<I9kArticleHeader title="Are AI coding tools ready to replace programmers?" image-src="https://i.ytimg.com/vi/NfRC9Lj4-rU/hqdefault.jpg" eager />

Fallback watermark

<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
PropTypeDefault
title*string
imageSrcstring | nullnull
imageAltstring''
watermarkstring'9k'
eagerbooleanfalse

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.

Variants

FeaturedOpen sourceAI
<div style="display: flex; flex-wrap: wrap; gap: var(--spacing-6)">
  <I9kBadge variant="solid">Featured</I9kBadge>
  <I9kBadge variant="outline">Open source</I9kBadge>
  <I9kBadge variant="tag">AI</I9kBadge>
</div>

Sizes

SmallMediumLarge
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: var(--spacing-6)">
  <I9kBadge size="sm" variant="tag">Small</I9kBadge>
  <I9kBadge size="md" variant="tag">Medium</I9kBadge>
  <I9kBadge size="lg" variant="tag">Large</I9kBadge>
</div>

Watch out

  • 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
PropTypeDefault
asstring | 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.

Sizes

12small stat
480k+monthly downloads
10+years building products
<div style="display: grid; gap: var(--spacing-13); grid-template-columns: repeat(3, minmax(0, 1fr))">
  <I9kStat label="small stat" size="sm" value="12" />
  <I9kStat label="monthly downloads" size="md" value="480k+" />
  <I9kStat label="years building products" size="lg" value="10+" />
</div>

Slot overrides

99.9%availability targetservice report
<I9kStat>
  <template #value><strong>99.9%</strong></template>
  <template #label>availability target</template>
  <template #source><a href="#" @click.prevent>service report</a></template>
</I9kStat>

Watch out

  • 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
PropTypeDefault
asstring | Component'div'
labelstringundefined
size'sm' | 'md' | 'lg''md'
sourcestringundefined
valuestring | numberundefined

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" />

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.

Badge and arrow

<I9kLinkCard name="vue3-carousel" url="https://github.com/ismail9k/vue3-carousel" description="A flexible, responsive carousel component for Vue 3." badge="Open source" arrow />

With image

<I9kLinkCard name="ismail9k" url="https://github.com/ismail9k" description="Personal GitHub profile and open-source projects." image="https://avatars.githubusercontent.com/u/20756985?s=120&v=4" badge="Library" />

Sizes

<div style="display: grid; gap: var(--component-gap-md)">
  <I9kLinkCard size="sm" name="Small card" url="https://example.com/small" description="Compact link card" />
  <I9kLinkCard size="md" name="Medium card" url="https://example.com/medium" description="Default link card" />
  <I9kLinkCard size="lg" name="Large card" url="https://example.com/large" description="Prominent link card" />
</div>

Watch out

  • 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
PropTypeDefault
name*string
url*string
description*string
imagestring | nullnull
badgestring | nullnull
showImagebooleantrue
arrowbooleanfalse
arrowLabelstring'↗'
size'sm' | 'md' | 'lg''md'
Emits
EventPayload
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.

Linked with thumbnail

January 25, 2026

Are AI coding tools ready to replace programmers?

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>

Sizes

January 25, 2026

Small timeline card

Compact summary.

January 25, 2026

Medium timeline card

Default summary.

January 25, 2026

Large timeline card

Prominent summary.
<I9kTimelineCard date="2026-01-25" size="sm"><template #title>Small timeline card</template>Compact summary.</I9kTimelineCard>
<I9kTimelineCard date="2026-01-25" size="md"><template #title>Medium timeline card</template>Default summary.</I9kTimelineCard>
<I9kTimelineCard date="2026-01-25" size="lg"><template #title>Large timeline card</template>Prominent summary.</I9kTimelineCard>

Watch out

  • `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
PropTypeDefault
date*string | Date
linkedbooleanfalse
localestring'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>

Sizes

<div style="display: grid; gap: var(--component-gap-md)">
  <I9kProfileCard size="sm" name="Small profile">Compact biography.</I9kProfileCard>
  <I9kProfileCard size="md" name="Medium profile">Default biography.</I9kProfileCard>
  <I9kProfileCard size="lg" name="Large profile">Prominent biography.</I9kProfileCard>
</div>

Watch out

  • 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
PropTypeDefault
name*string
aliasstring | nullnull
namePrefixstring | nullnull
avatarSrcstring | nullnull
avatarAltstring''
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>

I9kFaqList

List of collapsible question/answer pairs built on native <details>/<summary>. Use it for an FAQ section without wiring any open/close state yourself.

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
PropTypeDefault
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: open/close state is native browser <details> behavior — no Vue state is involved, and each item opens/closes independently with no "only one open at a time" accordion behavior.

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.

Default

<I9kGithubEmbed repo="ismail9k/vue3-carousel" />

Watch out

  • 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
PropTypeDefault
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 set

GitHubLinkedInXEmailDEV Community
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: var(--component-gap-md)">
  <I9kIcon name="github" title="GitHub" size="1.5em" />
  <I9kIcon name="linkedin" title="LinkedIn" size="1.5em" />
  <I9kIcon name="x" title="X" size="1.5em" />
  <I9kIcon name="mail" title="Email" size="1.5em" />
  <I9kIcon name="dev" title="DEV Community" size="1.5em" />
</div>

Sizes

GitHubGitHubGitHub
<div style="display: flex; align-items: center; gap: var(--component-gap-md)">
  <I9kIcon name="github" title="GitHub" size="1em" />
  <I9kIcon name="github" title="GitHub" size="1.5em" />
  <I9kIcon name="github" title="GitHub" size="2.5em" />
</div>

Watch out

  • 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
PropTypeDefault
name*I9kIconName
titlestring''
descstring''
sizestring | 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'.
- 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.

Expression set

^_^·ᴗ·◡̈>‿<x_x
<div class="cluster">
  <I9kAsciiEmoji name="^_^" size="lg" />
  <I9kAsciiEmoji name="·ᴗ·" size="lg" color="accent" />
  <I9kAsciiEmoji name="◡̈" size="lg" />
  <I9kAsciiEmoji name=">‿<" size="lg" color="muted" />
  <I9kAsciiEmoji name="x_x" size="lg" />
</div>

Sizes

^_^^_^^_^
<div style="display: inline-flex; align-items: center; gap: var(--component-gap-md)">
  <I9kAsciiEmoji name="^_^" size="sm" />
  <I9kAsciiEmoji name="^_^" size="md" />
  <I9kAsciiEmoji name="^_^" size="lg" />
</div>

Watch out

  • 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
PropTypeDefault
name*keyof typeof labels
labelstring | nullnull
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" />

Forms

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>

Sizes

<I9kField label="Small" size="sm"><I9kInput v-model="a" /></I9kField>
<I9kField label="Medium" size="md"><I9kInput v-model="b" /></I9kField>
<I9kField label="Large" size="lg"><I9kInput v-model="c" /></I9kField>

Required with error

<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
PropTypeDefault
labelstring''
hintstringundefined
errorstringundefined
requiredbooleanfalse
size'sm' | 'md' | 'lg''md'
controlIdstringundefined

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>

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.

Sizes

<I9kInput v-model="a" ui-size="sm" label="Small" />
<I9kInput v-model="b" ui-size="md" label="Medium" />
<I9kInput v-model="c" ui-size="lg" label="Large" />

Hint and error

We never share it.

<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
PropTypeDefault
modelValue*string
labelstringundefined
type'text' | 'email' | 'password''text'
errorstring | nullnull
hintstringundefined
requiredbooleanfalse
uiSize'sm' | 'md' | 'lg'undefined
Emits
EventPayload
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>

Sizes

<I9kField label="Small" size="sm"><I9kTextarea v-model="a" /></I9kField>
<I9kField label="Medium" size="md"><I9kTextarea v-model="b" /></I9kField>
<I9kField label="Large" size="lg"><I9kTextarea v-model="c" /></I9kField>

Error state

<I9kField label="Project details" error="Please provide project details.">
  <I9kTextarea v-model="details" />
</I9kField>

Watch out

  • The visual scale prop is `uiSize`, not `size`.
  • 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
PropTypeDefault
modelValue*string
uiSize'sm' | 'md' | 'lg'undefined
resize'vertical' | 'horizontal' | 'both' | 'none''vertical'
Emits
EventPayload
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.

Inside I9kField

<I9kField label="Service">
  <I9kSelect v-model="service">
    <option value="">Choose a service</option>
    <option value="audit">Technical audit</option>
    <option value="design">Design system</option>
    <option value="development">Development</option>
  </I9kSelect>
</I9kField>

Sizes

<I9kField label="Small" size="sm">
  <I9kSelect v-model="a"><option value="audit">Technical audit</option></I9kSelect>
</I9kField>
<I9kField label="Medium" size="md">
  <I9kSelect v-model="b"><option value="audit">Technical audit</option></I9kSelect>
</I9kField>
<I9kField label="Large" size="lg">
  <I9kSelect v-model="c"><option value="audit">Technical audit</option></I9kSelect>
</I9kField>

Error state

<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
PropTypeDefault
modelValue*string
uiSize'sm' | 'md' | 'lg'
Emits
EventPayload
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>

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

Choose a service
<I9kRadioGroup v-model="service" legend="Choose a service" :options="options" />

Card variant

Choose a service
<I9kRadioGroup
  v-model="service"
  legend="Choose a service"
  variant="card"
  :options="options"
/>

Required with error

Choose a service
<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
PropTypeDefault
modelValue*string
options*readonly I9kRadioOption[]
legend*string
namestringundefined
hintstringundefined
errorstringundefined
requiredbooleanfalse
disabledbooleanfalse
size'sm' | 'md' | 'lg''md'
variant'default' | 'card''default'
orientation'horizontal' | 'vertical''vertical'
Emits
EventPayload
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

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.

Variants

<I9kButton>Default</I9kButton>
<I9kButton variant="primary">Primary</I9kButton>
<I9kButton variant="link">Link</I9kButton>
<I9kButton variant="filter" active>Selected filter</I9kButton>
<I9kButton variant="pagination">Next</I9kButton>
<I9kButton variant="page" active>1</I9kButton>

As a link

<I9kButton href="https://example.com" variant="primary">Visit site</I9kButton>
<I9kButton href="https://example.com" variant="link">Learn more</I9kButton>

Watch out

  • The root element depends on props, not a separate mode flag: pass `to` or `href` for a link, omit both for a native button.
  • `type` is only meaningful on the button form — it is not rendered when the component resolves to `<a>` or a `linkComponent`.
  • In a Vue Router app, pass `link-component="RouterLink"` alongside `to` so navigation uses the router instead of a full page reload.
Props
PropTypeDefault
tostring | Record<string, unknown> | nullnull
hrefstring | nullnull
variant'default' | 'primary' | 'link' | 'filter' | 'pagination' | 'page''default'
size'sm' | 'md' | 'lg''md'
activebooleanfalse
type'button' | 'submit' | 'reset''button'
linkComponentstring | object | nullnull

Slots: default

Agent prompt

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.

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.

Horizontal (default)

<I9kButtonGroup label="Article actions">
  <I9kButton>Save</I9kButton>
  <I9kIconButton icon="mail" label="Email article" />
</I9kButtonGroup>

Vertical

<I9kButtonGroup label="Article actions" orientation="vertical">
  <I9kButton>Save draft</I9kButton>
  <I9kButton>Preview</I9kButton>
  <I9kIconButton icon="mail" label="Email article" />
</I9kButtonGroup>

Sizes (set on both the group and its children)

<I9kButtonGroup label="Small actions" size="sm">
  <I9kButton size="sm">Save</I9kButton>
  <I9kIconButton icon="mail" label="Email" size="sm" />
</I9kButtonGroup>
<I9kButtonGroup label="Large actions" size="lg">
  <I9kButton size="lg">Save</I9kButton>
  <I9kIconButton icon="mail" label="Email" size="lg" />
</I9kButtonGroup>

Watch out

  • `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
PropTypeDefault
size'sm' | 'md' | 'lg''md'
orientation'horizontal' | 'vertical''horizontal'
labelstringundefined

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.

Variants

<I9kIconButton icon="home" label="Home" />
<I9kIconButton icon="mail" label="Mail" variant="primary" />
<I9kIconButton icon="menu" label="Menu" variant="ghost" />

Sizes

<I9kIconButton icon="home" label="Small home" size="sm" />
<I9kIconButton icon="home" label="Medium home" size="md" />
<I9kIconButton icon="home" label="Large home" size="lg" />

As a link

<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
PropTypeDefault
icon*I9kIconName
label*string
tostring | Record<string, unknown> | nullnull
hrefstring | nullnull
variant'secondary' | 'primary' | 'ghost''secondary'
size'sm' | 'md' | 'lg''md'
type'button' | 'submit' | 'reset''button'
linkComponentstring | object | nullnull

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" />

Feedback

I9kToast

Styled, accessible message banner for status or error text. It is only the visual/ARIA shell — placement, timing, and dismissal are the caller's responsibility.

Variants

Your changes are syncing.
Changes saved.
<I9kToast variant="info">Your changes are syncing.</I9kToast>
<I9kToast variant="success">Changes saved.</I9kToast>
<I9kToast variant="error">Could not save changes.</I9kToast>

Sizes

Small notification
Medium notification
Large notification
<I9kToast size="sm">Small notification</I9kToast>
<I9kToast size="md">Medium notification</I9kToast>
<I9kToast size="lg">Large notification</I9kToast>

Watch out

  • I9kToast renders no dismiss control and sets no timer — show, hide, and stack it yourself; it is a static banner until you remove it from the DOM.
  • `variant="error"` renders `role="alert"` (assertive); `info` and `success` render `role="status"` (polite) — pick `error` only for genuine failures so screen readers don't interrupt for routine status text.
  • It has no positioning of its own — wrap it in a container with your own `position: fixed` styling if you want it to float above the page.
Props
PropTypeDefault
variant'info' | 'success' | 'error''info'
size'sm' | 'md' | 'lg''md'

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' | 'error' (default 'info') — also sets the ARIA role: 'error' renders `role="alert"`, 'info' and 'success' render `role="status"`.
- size?: 'sm' | 'md' | 'lg' (default 'md')

Emits: none.

Slots: default — the message content.

IMPORTANT: I9kToast has no dismiss button, no auto-hide timer, and no fixed/floating positioning built in. It is purely the visual banner and ARIA role — you own showing it, hiding it, stacking multiple toasts, and where on the page it sits (e.g. wrap it in your own fixed-position container to make it float).

Usage:
<I9kToast variant="success">Changes saved.</I9kToast>

Site chrome

I9kNavigation

Sticky site header with a brand slot, a link list, and an actions slot. Tracks scroll position to add a background on scroll and switch the brand into a compact state.

Default

<I9kNavigation
  :links="[
    { id: 'docs', label: 'Docs', href: '/docs' },
    { id: 'pricing', label: 'Pricing', href: '/pricing' },
  ]"
>
  <template #brand>Acme</template>
  <template #actions><I9kButton variant="primary" href="/signup">Sign up</I9kButton></template>
</I9kNavigation>

Compact-aware brand

<I9kNavigation :links="links" :compact-at="72" :expand-at="24">
  <template #brand="{ compact }">
    <strong v-if="!compact">Acme Studio</strong>
    <strong v-else>A</strong>
  </template>
</I9kNavigation>

Watch out

  • The `links` prop is a plain array of `{ id, label, href }` objects — the menu is not built from slot content.
  • The `brand` slot receives 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
PropTypeDefault
links*I9kNavigationLink[]
brandHrefstring'/'
brandLabelstring'Home'
compactAtnumber72
expandAtnumber24
Emits
EventPayload
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.

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 <a>. 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.

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.

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>

I9kFooter

A page's bottom chrome: an optional row of social links and an optional tagline, both replaceable via slots.

Tagline and social links

<div style="position: relative; border: 1px solid var(--border-color); border-radius: var(--radius-md);">
  <I9kFooter
    tagline="Built with the 9k design system."
    :social-links="[
      { name: 'GitHub', url: 'https://github.com/ismail9k', icon: 'github' },
      { name: 'Mail', url: 'mailto:hello@ismail9k.com', icon: 'mail' },
    ]"
  />
</div>

Custom footer content (default slot)

© 2026 Ismail9k. All rights reserved.

<div style="position: relative; border: 1px solid var(--border-color); border-radius: var(--radius-md);">
  <I9kFooter :social-links="[{ name: 'GitHub', url: 'https://github.com/ismail9k', icon: 'github' }]">
    <p style="margin: 0; font-size: 0.85rem;">© 2026 Ismail9k. All rights reserved.</p>
  </I9kFooter>
</div>

Watch out

  • The social links row is conditional on `socialLinks.length` — an empty array (the default) renders no I9kSocialLinks at all.
  • The default slot fully replaces the tagline paragraph rather than appending to it — use one or the other, not both.
  • I9kSocialLink is not exported from the package — inline `{ name: string; url: string; label?: string; icon?: I9kIconName }` yourself.
Props
PropTypeDefault
taglinestring | nullnull
socialLinksI9kSocialLink[]() => []
socialLabelsbooleanfalse
Emits
EventPayload
socialClick[item: I9kSocialLink, event: MouseEvent]

Slots: social-icondefault

Agent prompt

Use I9kFooter from @9klabs/design as a page's <footer>, for a social-links row and a short tagline.

import { I9kFooter } from '@9klabs/design';

Props:
- tagline?: string | null (default null) — plain text shown under the social links, only when the default slot is not used.
- socialLinks?: I9kSocialLink[] (default []) — rendered via I9kSocialLinks; the type is { name: string; url: string; label?: string; icon?: I9kIconName }. It is not exported from the package — inline the shape or declare your own local type.
- socialLabels?: boolean (default false) — forwarded to I9kSocialLinks to show text labels next to each icon.

Emits: socialClick — [item: I9kSocialLink, event: MouseEvent], forwarded from the underlying I9kSocialLinks click.

Slots:
- social-icon — forwarded straight through to I9kSocialLinks' own `icon` slot; receives one slot prop, item: I9kSocialLink, for the link being rendered.
- default — replaces the tagline paragraph entirely. Falls back to `<p>{{ tagline }}</p>` (only rendered when `tagline` is set) when no slot content is given.

IMPORTANT: the social links row only renders when `socialLinks` is a non-empty array — pass at least one entry or nothing appears.

IMPORTANT: the default slot and the `tagline` prop are mutually exclusive in effect — providing default slot content replaces the tagline paragraph outright, it is not appended alongside it.

Usage:
<I9kFooter
  tagline="Built with the 9k design system."
  :social-links="[{ name: 'GitHub', url: 'https://github.com/ismail9k', icon: 'github' }]"
  @social-click="onSocialClick"
/>

I9kBrandWordmark

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.

Full and compact forms

<div style="position: relative; display: flex; align-items: center; gap: 2rem; padding: var(--spacing-8); border: 1px solid var(--border-color); border-radius: var(--radius-md);">
  <I9kBrandWordmark />
  <I9kBrandWordmark compact />
</div>

Custom text pair

<div style="position: relative; padding: var(--spacing-8); border: 1px solid var(--border-color); border-radius: var(--radius-md);">
  <I9kBrandWordmark full="9kschool" short="9k" compact />
</div>

Watch out

  • 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
PropTypeDefault
compactbooleanfalse
fullstring'Ismail9k'
shortstring'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>

Row of pill-shaped social/contact links, each opening in a new tab, with an icon fallback and a customizable accessible label per item.

Icons, with labels

<div style="position: relative; padding: var(--spacing-8); border: 1px solid var(--border-color); border-radius: var(--radius-md);">
  <I9kSocialLinks :items="items" labels />
</div>

Fallback initial (no icon)

<div style="position: relative; padding: var(--spacing-8); border: 1px solid var(--border-color); border-radius: var(--radius-md);">
  <I9kSocialLinks :items="noIconItems" />
</div>

Watch out

  • 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
PropTypeDefault
items*I9kSocialLink[]
labelsbooleanfalse
followLabel(platform: string) => string(platform: string) => `Follow on ${platform}`
Emits
EventPayload
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:hello@ismail9k.com', icon: 'mail' },
  ]"
  @click="onSocialClick"
/>

I9kThemeSwitcher

Controlled light/dark toggle switch. 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)

<div style="position: relative; padding: var(--spacing-8); border: 1px solid var(--border-color); border-radius: var(--radius-md);">
  <I9kThemeSwitcher v-model="isDark" />
</div>

Watch out

  • 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 face (sun/moon) and label are shown — there is no internal toggle state, so it will not visually flip unless you update `modelValue` from the emitted event.
Props
PropTypeDefault
modelValuebooleanfalse
lightLabelstring'Switch to light mode'
darkLabelstring'Switch to dark mode'
Emits
EventPayload
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 switch 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 styled link to an alternate-language version of the current page. It is only the link's markup — it does not itself switch the page's language or navigate via JS.

Default (fallback text)

<div style="position: relative; padding: var(--spacing-8); border: 1px solid var(--border-color); border-radius: var(--radius-md);">
  <I9kLanguageSwitcher label="العربية" href="/ar" hreflang="ar" />
</div>

Custom content (default slot)

<div style="position: relative; padding: var(--spacing-8); border: 1px solid var(--border-color); border-radius: var(--radius-md);">
  <I9kLanguageSwitcher label="Arabic" href="/ar" hreflang="ar">🇸🇦 العربية</I9kLanguageSwitcher>
</div>

Watch out

  • 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).
  • `label` is required but renders only as the default slot's fallback text — passing slot content hides `label` entirely, though you must still supply it for the type.
Props
PropTypeDefault
label*string
href*string
hreflangstring | nullnull
linkComponentstring | object | nullnull

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) — fallback text shown only when no default slot content is given.
- href: string (required) — the destination URL, typically the same page in the other language.
- 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 text entirely; falls back to `{{ label }}` when empty.

IMPORTANT: `label` is required by its type but is only ever rendered as the default slot's fallback content — if you pass default slot content (e.g. a flag emoji plus text), `label` is never displayed. It is still required, so pass a plain-text equivalent even when you also supply slot content.

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="العربية" 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.

<div
  style="position: relative; height: 220px; overflow: hidden; border: 1px solid var(--border-color); border-radius: var(--radius-md); transform: translateZ(0);"
>
  <I9kBlurredCircles />
  <p style="position: relative; z-index: 1; margin: 0; padding: var(--spacing-8); color: var(--theme-text-color);">
    Real page content stacks above this ambient background layer.
  </p>
</div>

Watch out

  • 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>

Rules for agents

These are the constraints a props table cannot express. They hold for every component above.

  1. I9kInput names its visual scale prop `uiSize`, not `size`, so the native HTML `size` attribute stays available on the underlying input.
  2. Every component owns its appearance in `<style scoped>` and must not rely on global classes for its look.
  3. Components declare component-local custom properties on their root class and redefine them per size modifier, rather than consuming raw brand tokens for sizing.
  4. Sizes and tones come from the shared `I9kComponentSize` and `I9kTone` types. Do not redeclare those string unions per component.
  5. 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.
  6. I9kIcon renders from the local `src/icons/paths.json` set. Add new icons to that file rather than inlining SVG in a component.
  7. 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.
  8. Import the stylesheet once, at the application entry: `@9klabs/design/style.css`. It is the only CSS a consumer needs.
  9. Any visual change needs checking in light and dark themes and in both LTR and RTL directions.