Sprite
A pixel character built from swappable parts, that walks, sits and works.
New1'use client';23import { Sprite } from '@/components/ui/sprite';45export function Default() {6 return <Sprite seed="nachui" size={72} />;7}
Every sprite is five slots โ skin, hair, eyes, outfit, accessory โ drawn on one
fixed skeleton: the face box, the eye line, the shoulders and the hip. Because
the skeleton never moves, any combination of parts inherits the animations for
free.
Installation
Copy and paste the following code into your project.
1import type * as React from 'react';2import { cn } from '../lib/cn';34export const SPRITE_SKINS = {5 terracotta: { base: '#E8845A', shade: '#D4704A' },6 umber: { base: '#8A5A3C', shade: '#6E4630' },7 sand: { base: '#F0B589', shade: '#D99A6E' },8 clay: { base: '#C96F4E', shade: '#A85639' },9} as const;1011export const SPRITE_HAIR = {12 crop: 'M6 1h12v4H6ZM5 2h14v4H5Z',13 buzz: 'M6 3h12v2H6ZM5 4h14v1H5Z',14 bowl: 'M5 1h14v3H5ZM4 3h16v3H4Z',15 long: 'M6 1h12v4H6ZM5 2h14v4H5ZM4 5h2v10H4Zm14 0h2v10h-2Z',16 bun: 'M9 0h6v3H9ZM6 1h12v4H6ZM5 2h14v4H5Z',17 spikes: 'M5 3h2v2H5Zm3-1h2v3H8Zm3-1h2v4h-2Zm3 1h2v3h-2Zm3 1h2v2h-2ZM5 4h14v1H5Z',18} as const;1920export const SPRITE_HAIR_COLORS = {21 ink: '#2C1810',22 chestnut: '#5A3A2E',23 copper: '#B8763F',24 ash: '#D8D2C8',25 dye: '#3B4F6B',26} as const;2728export const SPRITE_EYES = {29 square: [30 [5, 8, 5, 5],31 [14, 8, 5, 5],32 ],33 slit: [34 [6, 9, 5, 2],35 [13, 9, 5, 2],36 ],37 dot: [38 [7, 9, 3, 3],39 [14, 9, 3, 3],40 ],41 wide: [42 [5, 8, 6, 4],43 [13, 8, 6, 4],44 ],45 visor: [[5, 8, 14, 4]],46} as const;4748export const SPRITE_OUTFITS = {49 tee: (main: string, trim: string) => [50 { d: 'M4 18h16v6H4Z', fill: main },51 { d: 'M9 18h6v2H9Z', fill: trim },52 { d: 'M2 19h2v5H2Zm18 0h2v5h-2Z', fill: trim },53 ],54 slab: (main: string, trim: string) => [55 { d: 'M4 18h16v6H4Z', fill: main },56 { d: 'M9 18h6v6H9Z', fill: trim },57 { d: 'M2 19h2v5H2Zm18 0h2v5h-2Z', fill: trim },58 ],59 hoodie: (main: string, trim: string) => [60 { d: 'M6 16h12v3H6Z', fill: trim },61 { d: 'M4 18h16v6H4Z', fill: main },62 { d: 'M11 19h2v5h-2Z', fill: trim },63 { d: 'M2 19h2v5H2Zm18 0h2v5h-2Z', fill: trim },64 ],65 overalls: (main: string, trim: string) => [66 { d: 'M4 18h16v6H4Z', fill: trim },67 { d: 'M6 20h12v4H6Z', fill: main },68 { d: 'M7 18h2v3H7Zm8 0h2v3h-2Z', fill: main },69 { d: 'M2 19h2v5H2Zm18 0h2v5h-2Z', fill: trim },70 ],71} as const;7273export const SPRITE_ACCESSORIES = {74 none: [],75 headset: [76 { d: 'M5 2h14v2H5ZM3 3h2v6H3Zm16 0h2v6h-2Z', fill: '#222' },77 { d: 'M2 8h4v5H2Zm16 0h4v5h-4Z', fill: '#444' },78 { d: 'M2 9h2v3H2Zm18 0h2v3h-2Z', fill: '#333' },79 { d: 'M21 12h1v4h-1Z', fill: '#333' },80 { d: 'M20 15h3v2h-3Z', fill: '#444' },81 ],82 glasses: [83 { d: 'M4 8h7v1H4Zm0 4h7v1H4Zm0-4h1v5H4Zm6 0h1v5h-1Z', fill: '#2C1810' },84 { d: 'M13 8h7v1h-7Zm0 4h7v1h-7Zm0-4h1v5h-1Zm6 0h1v5h-1Z', fill: '#2C1810' },85 { d: 'M11 9h2v1h-2Z', fill: '#2C1810' },86 { d: 'M5 9h5v3H5Zm9 0h5v3h-5Z', fill: '#A8E6D7', opacity: 0.22 },87 ],88 cap: [89 { d: 'M5 1h14v4H5ZM4 3h16v2H4Z', fill: '#3B4F6B' },90 { d: 'M2 5h9v1H2Z', fill: '#2F3F57' },91 { d: 'M11 2h2v2h-2Z', fill: '#E8E0D5' },92 ],93 band: [94 { d: 'M4 4h16v2H4Z', fill: '#A8E6D7' },95 { d: 'M4 4h16v1H4Z', fill: '#8FD3C2' },96 ],97} as const;9899export type SpriteSkin = keyof typeof SPRITE_SKINS;100export type SpriteHair = keyof typeof SPRITE_HAIR;101export type SpriteHairColor = keyof typeof SPRITE_HAIR_COLORS;102export type SpriteEyes = keyof typeof SPRITE_EYES;103export type SpriteOutfit = keyof typeof SPRITE_OUTFITS;104export type SpriteAccessory = keyof typeof SPRITE_ACCESSORIES;105export type SpriteState = 'bust' | 'idle' | 'walk' | 'work' | 'loop';106107export interface SpriteParts {108 skin: SpriteSkin;109 hair: SpriteHair;110 hairColor: SpriteHairColor;111 eyes: SpriteEyes;112 outfit: SpriteOutfit;113 outfitMain: string;114 outfitTrim: string;115 accessory: SpriteAccessory;116 eyeFill?: string;117}118119const OUTFIT_COLORS = [120 ['#F5F0E8', '#D4704A'],121 ['#1A1A1A', '#2A2A2A'],122 ['#5B6B8C', '#46536E'],123 ['#E5C24A', '#C9A227'],124 ['#4E6E58', '#3C5645'],125 ['#8E7BA8', '#74628C'],126] as const;127128function hash(seed: string): number {129 let h = 2166136261;130 for (let i = 0; i < seed.length; i += 1) {131 h ^= seed.charCodeAt(i);132 h = Math.imul(h, 16777619);133 }134 return h >>> 0;135}136137function pick<T>(list: readonly T[], n: number): T {138 return list[n % list.length] as T;139}140141export function partsFromSeed(seed: string): SpriteParts {142 const h = hash(seed);143 const colors = pick(OUTFIT_COLORS, h >>> 18);144 return {145 skin: pick(Object.keys(SPRITE_SKINS) as SpriteSkin[], h),146 hair: pick(Object.keys(SPRITE_HAIR) as SpriteHair[], h >>> 3),147 hairColor: pick(Object.keys(SPRITE_HAIR_COLORS) as SpriteHairColor[], h >>> 6),148 eyes: pick(Object.keys(SPRITE_EYES) as SpriteEyes[], h >>> 9),149 outfit: pick(Object.keys(SPRITE_OUTFITS) as SpriteOutfit[], h >>> 12),150 outfitMain: colors[0],151 outfitTrim: colors[1],152 accessory: pick(Object.keys(SPRITE_ACCESSORIES) as SpriteAccessory[], h >>> 15),153 };154}155156const VISOR_GLOW = '#A8E6D7';157158const SAFE_COLOR =159 /^(?:#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})|[a-z]+|(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\([\w\s.,%/+-]*\)|var\(--[\w-]+\))$/i;160161function has<T extends object>(table: T, key: unknown): key is keyof T {162 return typeof key === 'string' && Object.prototype.hasOwnProperty.call(table, key);163}164165function safeColor(value: unknown): string | undefined {166 return typeof value === 'string' && SAFE_COLOR.test(value.trim()) ? value.trim() : undefined;167}168169function escapeXml(text: string): string {170 return text.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);171}172173function resolveParts(seed: string, override: Partial<SpriteParts> = {}): SpriteParts {174 const seeded = partsFromSeed(String(seed));175 return normalize({176 skin: has(SPRITE_SKINS, override.skin) ? override.skin : seeded.skin,177 hair: has(SPRITE_HAIR, override.hair) ? override.hair : seeded.hair,178 hairColor: has(SPRITE_HAIR_COLORS, override.hairColor) ? override.hairColor : seeded.hairColor,179 eyes: has(SPRITE_EYES, override.eyes) ? override.eyes : seeded.eyes,180 outfit: has(SPRITE_OUTFITS, override.outfit) ? override.outfit : seeded.outfit,181 outfitMain: safeColor(override.outfitMain) ?? seeded.outfitMain,182 outfitTrim: safeColor(override.outfitTrim) ?? seeded.outfitTrim,183 accessory: has(SPRITE_ACCESSORIES, override.accessory) ? override.accessory : seeded.accessory,184 eyeFill: safeColor(override.eyeFill),185 });186}187188function normalize(parts: SpriteParts): SpriteParts {189 if (parts.eyes !== 'visor') return parts;190 return {191 ...parts,192 eyeFill: parts.eyeFill ?? VISOR_GLOW,193 accessory: parts.accessory === 'glasses' ? 'none' : parts.accessory,194 };195}196197export const SPRITE_CSS = `.sprite {198 display: block;199 overflow: visible;200}201202.sprite-eye {203 transform-box: fill-box;204 transform-origin: center;205 animation: sprite-blink 3s infinite;206}207208.sprite-eyes {209 animation: sprite-glance 9s infinite;210}211212.sprite-breathe {213 animation: sprite-breathe 3.6s ease-in-out infinite;214}215216.sprite-idle .sprite-legs-sit,217.sprite-walk .sprite-legs-sit {218 display: none;219}220221.sprite-walk .sprite-gait {222 animation: sprite-bob 480ms steps(1, end) infinite;223}224225.sprite-leg-frame {226 animation: sprite-frame 480ms steps(1, end) infinite;227}228229.sprite-leg-frame + .sprite-leg-frame {230 animation-delay: 240ms;231}232233.sprite-leg-dangle {234 animation: sprite-swing 2.4s ease-in-out infinite;235}236237.sprite-leg-dangle + .sprite-leg-dangle {238 animation-delay: 320ms;239}240241.sprite-work .sprite-laptop {242 animation: sprite-laptop-in 600ms cubic-bezier(0.25, 0.9, 0.35, 1) 500ms both;243}244245.sprite-work .sprite-lid {246 transform-box: fill-box;247 transform-origin: bottom center;248 animation: sprite-lid 450ms ease-out 1000ms both;249}250251.sprite-work .sprite-mark {252 animation: sprite-mark 400ms steps(1, end) 1450ms both;253}254255.sprite-work .sprite-spill {256 opacity: 0;257 animation: sprite-spill 900ms ease-out 1450ms both;258}259260.sprite-work .sprite-glow {261 opacity: 0;262 animation: sprite-lit 900ms ease-out 1450ms both;263}264265.sprite-work .sprite-glint {266 opacity: 0;267 animation: sprite-on 300ms steps(1, end) 1450ms both;268}269270.sprite-work .sprite-lean {271 animation: sprite-lean 500ms ease-out 1400ms both;272}273274.sprite-work .sprite-hands {275 opacity: 0;276 animation: sprite-hands 350ms ease-out 1600ms both;277}278279.sprite-work .sprite-eyes {280 animation: sprite-read 3.4s steps(1, end) infinite;281}282283.sprite-hand {284 animation: sprite-tap 300ms steps(2, end) infinite;285}286287.sprite-hand + .sprite-hand {288 animation-delay: 150ms;289}290291.sprite-loop {292 --sprite-leg: 13s;293}294295.sprite-loop .sprite-sit {296 animation: sprite-cycle-sit var(--sprite-leg) ease-out infinite;297}298299.sprite-loop .sprite-gait {300 animation:301 sprite-bob 480ms steps(1, end) infinite,302 sprite-cycle-bob var(--sprite-leg) steps(1, end) infinite;303}304305.sprite-loop .sprite-legs-walk {306 animation: sprite-cycle-walk var(--sprite-leg) steps(1, end) infinite;307}308309.sprite-loop .sprite-legs-sit {310 animation: sprite-cycle-dangle var(--sprite-leg) steps(1, end) infinite;311}312313.sprite-loop .sprite-laptop {314 animation: sprite-cycle-laptop var(--sprite-leg) ease-out infinite;315}316317.sprite-loop .sprite-lid {318 transform-box: fill-box;319 transform-origin: bottom center;320 animation: sprite-cycle-lid var(--sprite-leg) ease-out infinite;321}322323.sprite-loop .sprite-mark {324 animation: sprite-cycle-mark var(--sprite-leg) steps(1, end) infinite;325}326327.sprite-loop .sprite-spill {328 animation: sprite-cycle-spill var(--sprite-leg) ease-out infinite;329}330331.sprite-loop .sprite-glow {332 animation: sprite-cycle-glow var(--sprite-leg) ease-out infinite;333}334335.sprite-loop .sprite-glint {336 animation: sprite-cycle-glint var(--sprite-leg) steps(1, end) infinite;337}338339.sprite-loop .sprite-lean {340 animation: sprite-cycle-lean var(--sprite-leg) ease-out infinite;341}342343.sprite-loop .sprite-hands {344 animation: sprite-cycle-hands var(--sprite-leg) ease-out infinite;345}346347@keyframes sprite-blink {348 0%,349 80% {350 transform: scaleY(1);351 }352 82%,353 84% {354 transform: scaleY(0);355 }356 86%,357 100% {358 transform: scaleY(1);359 }360}361362@keyframes sprite-glance {363 0%,364 60%,365 100% {366 translate: 0 0;367 }368 64%,369 70% {370 translate: 1.4px 0;371 }372 76%,373 82% {374 translate: -1.4px 0;375 }376}377378@keyframes sprite-breathe {379 0%,380 100% {381 translate: 0 0;382 }383 50% {384 translate: 0 0.22px;385 }386}387388@keyframes sprite-bob {389 0% {390 translate: 0 0;391 }392 50% {393 translate: 0 var(--sprite-bob, -1px);394 }395 100% {396 translate: 0 0;397 }398}399400@keyframes sprite-frame {401 0% {402 opacity: 1;403 }404 50%,405 100% {406 opacity: 0;407 }408}409410@keyframes sprite-swing {411 0%,412 100% {413 translate: 0 0;414 }415 50% {416 translate: 0 -0.6px;417 }418}419420@keyframes sprite-laptop-in {421 from {422 translate: 0 15px;423 opacity: 0;424 }425 20% {426 opacity: 1;427 }428 80% {429 translate: 0 -0.8px;430 }431 to {432 translate: 0 0;433 opacity: 1;434 }435}436437@keyframes sprite-lid {438 from {439 scale: 1 0;440 }441 70% {442 scale: 1 1.12;443 }444 to {445 scale: 1 1;446 }447}448449@keyframes sprite-mark {450 from {451 fill: #2f2f2f;452 }453 20% {454 fill: #d8f5ec;455 }456 45%,457 to {458 fill: #a8e6d7;459 }460}461462@keyframes sprite-spill {463 from {464 opacity: 0;465 }466 20% {467 opacity: 0.9;468 }469 55%,470 to {471 opacity: 0.45;472 }473}474475@keyframes sprite-lit {476 from {477 opacity: 0;478 }479 20% {480 opacity: 0.5;481 }482 55%,483 to {484 opacity: 0.16;485 }486}487488@keyframes sprite-on {489 from {490 opacity: 0;491 }492 to {493 opacity: 1;494 }495}496497@keyframes sprite-lean {498 from {499 translate: 0 0;500 }501 to {502 translate: 0 0.55px;503 }504}505506@keyframes sprite-hands {507 from {508 opacity: 0;509 translate: 0 3px;510 }511 to {512 opacity: 1;513 translate: 0 0;514 }515}516517@keyframes sprite-tap {518 0%,519 100% {520 translate: 0 0;521 }522 50% {523 translate: 0 -1px;524 }525}526527@keyframes sprite-read {528 0% {529 translate: 0 1.2px;530 }531 15% {532 translate: -1.4px 1.2px;533 }534 35% {535 translate: 1.4px 1.2px;536 }537 55% {538 translate: -1.4px 1.2px;539 }540 75% {541 translate: 1.1px 1.2px;542 }543 90% {544 translate: -1.4px 1.2px;545 }546 100% {547 translate: 0 1.2px;548 }549}550551@keyframes sprite-cycle-sit {552 0%,553 40% {554 translate: 0 0;555 }556 46%,557 93% {558 translate: 0 4px;559 }560 98%,561 100% {562 translate: 0 0;563 }564}565566@keyframes sprite-cycle-bob {567 0%,568 39% {569 --sprite-bob: -1px;570 }571 40%,572 96% {573 --sprite-bob: 0px;574 }575 97%,576 100% {577 --sprite-bob: -1px;578 }579}580581@keyframes sprite-cycle-walk {582 0%,583 40% {584 opacity: 1;585 }586 41%,587 92% {588 opacity: 0;589 }590 93%,591 100% {592 opacity: 1;593 }594}595596@keyframes sprite-cycle-dangle {597 0%,598 40% {599 opacity: 0;600 }601 41%,602 92% {603 opacity: 1;604 }605 93%,606 100% {607 opacity: 0;608 }609}610611@keyframes sprite-cycle-laptop {612 0%,613 55% {614 translate: 0 15px;615 opacity: 0;616 }617 58% {618 translate: 0 -0.8px;619 opacity: 1;620 }621 60%,622 87% {623 translate: 0 0;624 opacity: 1;625 }626 91%,627 100% {628 translate: 0 15px;629 opacity: 0;630 }631}632633@keyframes sprite-cycle-lid {634 0%,635 61% {636 scale: 1 0;637 }638 65% {639 scale: 1 1.12;640 }641 67%,642 88% {643 scale: 1 1;644 }645 90%,646 100% {647 scale: 1 0;648 }649}650651@keyframes sprite-cycle-mark {652 0%,653 65% {654 fill: #2f2f2f;655 }656 66% {657 fill: #d8f5ec;658 }659 68%,660 88% {661 fill: #a8e6d7;662 }663 89%,664 100% {665 fill: #2f2f2f;666 }667}668669@keyframes sprite-cycle-spill {670 0%,671 65% {672 opacity: 0;673 }674 67% {675 opacity: 0.9;676 }677 70%,678 87% {679 opacity: 0.45;680 }681 90%,682 100% {683 opacity: 0;684 }685}686687@keyframes sprite-cycle-glow {688 0%,689 65% {690 opacity: 0;691 }692 67% {693 opacity: 0.5;694 }695 70%,696 87% {697 opacity: 0.16;698 }699 90%,700 100% {701 opacity: 0;702 }703}704705@keyframes sprite-cycle-glint {706 0%,707 65% {708 opacity: 0;709 }710 66%,711 88% {712 opacity: 1;713 }714 89%,715 100% {716 opacity: 0;717 }718}719720@keyframes sprite-cycle-lean {721 0%,722 56% {723 translate: 0 0;724 }725 64%,726 87% {727 translate: 0 0.55px;728 }729 92%,730 100% {731 translate: 0 0;732 }733}734735@keyframes sprite-cycle-hands {736 0%,737 67% {738 opacity: 0;739 translate: 0 3px;740 }741 70%,742 87% {743 opacity: 1;744 translate: 0 0;745 }746 90%,747 100% {748 opacity: 0;749 translate: 0 3px;750 }751}752753@media (prefers-reduced-motion: reduce) {754 .sprite,755 .sprite * {756 animation: none !important;757 }758759 .sprite-leg-frame + .sprite-leg-frame {760 opacity: 0;761 }762763 .sprite-loop .sprite-legs-walk {764 opacity: 0;765 }766767 .sprite-loop .sprite-sit {768 translate: 0 4px;769 }770771 .sprite-work .sprite-eyes {772 translate: 0 1.2px;773 }774775 .sprite-work .sprite-lean,776 .sprite-loop .sprite-lean {777 translate: 0 0.55px;778 }779780 .sprite-work .sprite-mark,781 .sprite-loop .sprite-mark {782 fill: #a8e6d7;783 }784785 .sprite-work .sprite-glow,786 .sprite-loop .sprite-glow {787 opacity: 0.16;788 }789790 .sprite-work .sprite-spill {791 opacity: 0.45;792 }793794 .sprite-work .sprite-glint,795 .sprite-work .sprite-hands,796 .sprite-loop .sprite-spill,797 .sprite-loop .sprite-glint,798 .sprite-loop .sprite-hands {799 opacity: 1;800 }801}802`;803804const SPRITE_STYLE_HREF = 'nachui-sprite';805806const VIEW_BOX: Record<SpriteState, string> = {807 bust: '0 0 24 24',808 idle: '0 0 32 28',809 walk: '0 0 32 28',810 work: '0 0 32 30',811 loop: '0 0 32 28',812};813814const RATIO: Record<SpriteState, number> = {815 bust: 24 / 24,816 idle: 28 / 32,817 walk: 28 / 32,818 work: 30 / 32,819 loop: 28 / 32,820};821822const rect = (x: number, y: number, w: number, h: number, fill: string, cls?: string) =>823 `<rect${cls ? ` class="${cls}"` : ''} x="${x}" y="${y}" width="${w}" height="${h}" fill="${fill}"/>`;824825const path = (d: string, fill: string, opacity?: number) =>826 `<path d="${d}" fill="${fill}"${opacity ? ` opacity="${opacity}"` : ''}/>`;827828function face(parts: SpriteParts, lit: boolean): string {829 const skin = SPRITE_SKINS[parts.skin];830 const outfit = SPRITE_OUTFITS[parts.outfit](parts.outfitMain, parts.outfitTrim);831832 const eyes = SPRITE_EYES[parts.eyes]833 .map(([x, y, w, h]) => {834 const glint = lit835 ? rect(x + 1, y + h - 2, Math.max(2, w - 2), 1, '#A8E6D7', 'sprite-glint')836 : '';837 return `<g class="sprite-eye">${rect(x, y, w, h, parts.eyeFill ?? '#2C1810')}${glint}</g>`;838 })839 .join('');840841 return [842 path('M4 4h16v12H4Z', skin.base),843 path('M2 8h2v5H2Zm18 0h2v5h-2Z', skin.shade),844 path('M10 16h4v2h-4Z', skin.base),845 ...outfit.map((p) => path(p.d, p.fill)),846 path(SPRITE_HAIR[parts.hair], SPRITE_HAIR_COLORS[parts.hairColor]),847 lit ? rect(4, 4, 16, 12, '#A8E6D7', 'sprite-glow') : '',848 `<g class="sprite-eyes">${eyes}</g>`,849 ...SPRITE_ACCESSORIES[parts.accessory].map((p) =>850 path(p.d, p.fill, 'opacity' in p ? (p.opacity as number) : undefined),851 ),852 ].join('');853}854855function legs(parts: SpriteParts): string {856 const skin = SPRITE_SKINS[parts.skin];857 return (858 `<g class="sprite-legs-walk">` +859 `<g class="sprite-leg-frame">${rect(10, 24, 3, 3, skin.base)}${rect(19, 24, 3, 3, skin.base)}${rect(9, 27, 4, 1, skin.shade)}${rect(19, 27, 4, 1, skin.shade)}</g>` +860 `<g class="sprite-leg-frame">${rect(12, 24, 3, 3, skin.base)}${rect(17, 24, 3, 3, skin.base)}${rect(12, 27, 3, 1, skin.shade)}${rect(17, 27, 3, 1, skin.shade)}</g>` +861 `</g>` +862 `<g class="sprite-legs-sit">` +863 `<g class="sprite-leg-dangle">${rect(12, 24, 3, 5, skin.base)}${rect(11, 29, 4, 1, skin.shade)}</g>` +864 `<g class="sprite-leg-dangle">${rect(17, 24, 3, 5, skin.base)}${rect(17, 29, 4, 1, skin.shade)}</g>` +865 `</g>`866 );867}868869function laptop(parts: SpriteParts): string {870 const skin = SPRITE_SKINS[parts.skin];871 return (872 `<g class="sprite-laptop">` +873 `<g class="sprite-lid">${rect(7, 17, 18, 8, '#444')}${rect(8, 18, 16, 6, '#3A3A3A')}${rect(14, 20, 4, 3, '#2F2F2F', 'sprite-mark')}${rect(8, 17, 16, 1, '#A8E6D7', 'sprite-spill')}</g>` +874 rect(5, 25, 22, 3, '#333') +875 rect(7, 26, 18, 1, '#555') +876 rect(4, 27, 24, 1, '#555') +877 `<g class="sprite-hands">` +878 `<g class="sprite-hand">${rect(9, 25, 4, 2, skin.base)}${rect(9, 25, 4, 1, skin.shade)}</g>` +879 `<g class="sprite-hand">${rect(19, 25, 4, 2, skin.base)}${rect(19, 25, 4, 1, skin.shade)}</g>` +880 `</g></g>`881 );882}883884function drawing(parts: SpriteParts, state: SpriteState): string {885 if (state === 'bust') return face(parts, false);886887 const lit = state === 'work' || state === 'loop';888 const body =889 `<g class="sprite-lean"><g class="sprite-breathe">` +890 `<g transform="translate(4 ${state === 'work' ? 1 : 0})">${face(parts, lit)}</g>` +891 `</g></g>`;892893 if (state === 'loop') {894 return (895 `<g class="sprite-sit"><g class="sprite-gait">${body}${legs(parts)}</g>` +896 `<g transform="translate(0 -1)">${laptop(parts)}</g></g>`897 );898 }899900 return (901 `<g class="sprite-gait">${body}${state === 'work' ? '' : legs(parts)}</g>` +902 (state === 'work' ? laptop(parts) : '')903 );904}905906export interface SpriteToSvgOptions {907 seed?: string;908 parts?: Partial<SpriteParts>;909 state?: SpriteState;910 size?: number;911 withStyles?: boolean;912 label?: string;913}914915export function spriteToSvg({916 seed = 'nachui',917 parts: override,918 state = 'idle',919 size = 48,920 withStyles = true,921 label,922}: SpriteToSvgOptions = {}): string {923 const parts = resolveParts(seed, override);924 const safeState = has(VIEW_BOX, state) ? state : 'idle';925 const width = typeof size === 'number' && Number.isFinite(size) && size > 0 ? size : 48;926 const height = Math.round(width * RATIO[safeState]);927 const style = withStyles ? `<style>${SPRITE_CSS}</style>` : '';928 const title = label ? `<title>${escapeXml(String(label))}</title>` : '';929930 return (931 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${VIEW_BOX[safeState]}" width="${width}" height="${height}" ` +932 `shape-rendering="crispEdges" class="sprite sprite-${safeState}"${label ? ' role="img"' : ' aria-hidden="true"'}>` +933 title +934 style +935 drawing(parts, safeState) +936 `</svg>`937 );938}939940interface SpriteProps extends Omit<React.SVGAttributes<SVGSVGElement>, 'children'> {941 seed?: string;942 parts?: Partial<SpriteParts>;943 state?: SpriteState;944 size?: number;945 withStyles?: boolean;946 label?: string;947 ref?: React.Ref<SVGSVGElement>;948}949950function Sprite({951 seed = 'nachui',952 parts: override,953 state = 'idle',954 size = 48,955 withStyles = false,956 label,957 className,958 ref,959 ...props960}: SpriteProps) {961 const parts = resolveParts(seed, override);962 const safeState = has(VIEW_BOX, state) ? state : 'idle';963 const title = label ? `<title>${escapeXml(label)}</title>` : '';964 const named = Boolean(label || props['aria-label'] || props['aria-labelledby']);965966 return (967 <>968 {withStyles && (969 <style href={SPRITE_STYLE_HREF} precedence="default">970 {SPRITE_CSS}971 </style>972 )}973 <svg974 ref={ref}975 viewBox={VIEW_BOX[safeState]}976 width={size}977 height={Math.round(size * RATIO[safeState])}978 xmlns="http://www.w3.org/2000/svg"979 shapeRendering="crispEdges"980 role={named ? 'img' : undefined}981 aria-label={label || undefined}982 aria-hidden={named ? undefined : true}983 className={cn('sprite', `sprite-${safeState}`, className)}984 dangerouslySetInnerHTML={{ __html: title + drawing(parts, safeState) }}985 {...props}986 />987 </>988 );989}990991Sprite.displayName = 'Sprite';992993export { Sprite };
Update the import paths to match your project structure.
Installing by hand works the same as any other component: copy the file from
the Manual tab and adjust the import paths. The animations travel inside the
file as
SPRITE_CSS, so nothing else needs copying โ either render with
withStyles or inject the string once:1<style>{SPRITE_CSS}</style>
Anatomy
1import { Sprite } from '@/components/ui/sprite';
1<Sprite seed="nachui" />
Seeds
Any string builds a sprite, and the same string always builds the same one โ so
a username or an email works as an avatar with nothing stored.
nachui
figueroaignacio
mate
bit
vera
lupe
1'use client';23import { Sprite } from '@/components/ui/sprite';45const seeds = ['nachui', 'figueroaignacio', 'mate', 'bit', 'vera', 'lupe'];67export function Seeds() {8 return (9 <div className="flex flex-wrap items-end gap-8">10 {seeds.map((seed) => (11 <div key={seed} className="flex flex-col items-center gap-3">12 <Sprite seed={seed} size={64} />13 <span className="text-muted-foreground font-mono text-xs">{seed}</span>14 </div>15 ))}16 </div>17 );18}
States
idle breathes and blinks, walk runs the two-frame walk cycle, work opens a
laptop, and loop plays the whole thing: walk in, sit down, work, get up.idle
walk
work
loop
1'use client';23import { Sprite } from '@/components/ui/sprite';45const states = [6 { state: 'idle', label: 'idle' },7 { state: 'walk', label: 'walk' },8 { state: 'work', label: 'work' },9 { state: 'loop', label: 'loop' },10] as const;1112export function States() {13 return (14 <div className="flex flex-wrap items-end gap-8">15 {states.map(({ state, label }) => (16 <div key={state} className="flex flex-col items-center gap-3">17 <Sprite seed="nachui" state={state} size={72} />18 <span className="text-muted-foreground font-mono text-xs">{label}</span>19 </div>20 ))}21 </div>22 );23}
Parts
Pass
parts to override whatever the seed picked, slot by slot. Colour is a
separate axis from shape, so six hair shapes and five colours are thirty heads.1'use client';23import { Sprite } from '@/components/ui/sprite';45export function Parts() {6 return (7 <div className="flex flex-wrap items-end gap-8">8 <Sprite9 seed="nachui"10 size={64}11 parts={{ hair: 'bun', hairColor: 'copper', eyes: 'wide', accessory: 'glasses' }}12 />13 <Sprite14 seed="nachui"15 size={64}16 parts={{ skin: 'umber', outfit: 'overalls', outfitMain: '#4E6E58', accessory: 'none' }}17 />18 <Sprite19 seed="nachui"20 size={64}21 parts={{ eyes: 'visor', eyeFill: '#A8E6D7', hair: 'buzz', hairColor: 'ash' }}22 />23 </div>24 );25}
Standalone
spriteToSvg returns the whole sprite as one SVG string, animations included, so
it works anywhere a page's stylesheet cannot reach โ a server response, a saved
file, a README. On this site every sprite is also a URL:1/api/sprite/{seed}.svg?state=loop&size=96
The same seed always answers with the same image, so the response is cached as
immutable.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
seed | string | nachui | Any string. The same string builds the same sprite. |
parts | Partial<SpriteParts> | โ | Overrides the seed, slot by slot. |
state | bust | idle | walk | work | loop | idle | What it is doing. |
size | number | 48 | Width in pixels; height follows the grid. |
label | string | - | Turns the sprite into a named image. Without it the SVG is hidden from assistive tech. |
withStyles | boolean | false | Adds SPRITE_CSS to the page. It is added once, however many sprites ask for it. |
Accessibility
By default the sprite is decorative and hidden from assistive tech. When it
stands for a person, put the name next to it in text, or pass
label when the
drawing is the only thing there and it should be read as an image.With reduced motion turned on, every animation stops and each state holds a
still pose. The rule ships inside
SPRITE_CSS, so standalone SVGs follow it too.Found something to improve?
Notice a bug, typo, or missing detail on this page? Help us make the documentation better by opening a GitHub issue.