I9kNino
Nino, the 9k pixel mascot: an inline-SVG character with eight expressions (one of them hide and seek, one dizzy), continuous ambient life (breathing, blinks, an antenna pulse, idle glances), a 300ms face morph 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.
ts
import { I9kNino } from '@9klabs/design';Examples
Expression set
vue
<div style="display: flex; flex-wrap: wrap; align-items: end; gap: var(--component-gap-lg)">
<I9kNino expression="idle" />
<I9kNino expression="happy" />
<I9kNino expression="thinking" />
<I9kNino expression="worried" />
<I9kNino expression="surprised" />
<I9kNino expression="eyes-closed" />
<I9kNino expression="hiding" />
<I9kNino expression="dizzy" />
</div>Looking around
vue
<div style="display: flex; flex-wrap: wrap; align-items: end; gap: var(--component-gap-lg)">
<I9kNino look="center" />
<I9kNino look="up" />
<I9kNino look="down" />
<I9kNino look="start" />
<I9kNino look="end" />
</div>Sizes and a still Nino
vue
<div style="display: flex; flex-wrap: wrap; align-items: end; gap: var(--component-gap-lg)">
<I9kNino size="sm" />
<I9kNino size="md" />
<I9kNino size="lg" />
<I9kNino size="lg" expression="worried" :animated="false" />
</div>Named, for when the mascot is content
vue
<I9kNino size="lg" expression="happy" label="Nino" />Actions, played from your own events
vue
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: var(--component-gap-md)">
<I9kNino ref="nino" size="lg" />
<I9kButton size="sm" @click="$refs.nino.play('wave')">Wave</I9kButton>
<I9kButton size="sm" @click="$refs.nino.play('jump')">Jump</I9kButton>
<I9kButton size="sm" @click="$refs.nino.play('nod')">Nod</I9kButton>
<I9kButton size="sm" @click="$refs.nino.play('shake')">Shake</I9kButton>
<I9kButton size="sm" @click="$refs.nino.play('dizzy')">Dizzy</I9kButton>
</div>Talking in any mood
vue
<div style="display: flex; flex-wrap: wrap; align-items: end; gap: var(--component-gap-lg)">
<I9kNino size="lg" talking />
<I9kNino size="lg" expression="worried" talking />
</div>Watch out
- 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
labelhe 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: falseand 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.lookmoves 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 120ms later, once the face has squashed shut. Read the prop, not the rendered class, when you test what mood you asked for.
- While hiding,
lookhas 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.
API
Props
| Prop | Type | Default | Required |
|---|---|---|---|
expression | I9kNinoExpression | 'idle' | no |
look | 'center' | 'up' | 'down' | 'start' | 'end' | 'center' | no |
size | 'sm' | 'md' | 'lg' | 'auto' | 'md' | no |
animated | boolean | true | no |
talking | boolean | false | no |
shadow | boolean | false | no |
label | string | null | null | no |
Emits
| Event | Payload |
|---|---|
action-end | [result: I9kNinoActionResult] |
Types
ts
export type I9kNinoExpression = (typeof I9K_NINO_EXPRESSIONS)[number];
export interface I9kNinoActionResult {
action: I9kNinoAction;
completed: boolean;
}Agent prompt
Paste this into an agent to have it use the component correctly. It is self-contained.
text
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. Motion is eased: resting poses sit on the grid, and parts glide between cells.
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 300ms morph: the face squashes shut, the new face swaps in at 120ms, and it opens.
- 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. A look change glides over 200ms.
- 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');