Sprite
Un personaje pixel armado con partes intercambiables, que camina, se sienta y programa.
Nuevo1'use client';23import { Sprite } from '@/components/ui/sprite';45export function Default() {6 return <Sprite seed="nachui" size={72} />;7}
Cada sprite son cinco ranuras — piel, pelo, ojos, ropa y accesorio — dibujadas
sobre un mismo esqueleto fijo: la caja de la cara, la línea de ojos, los hombros
y la cadera. Como el esqueleto no se mueve, cualquier combinación de partes
hereda las animaciones sin trabajo extra.
Instalación
Copia y pega el siguiente código en tu proyecto.
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 };
Actualiza las rutas de importación para que coincidan con la estructura de tu proyecto.
Instalarlo a mano funciona como con cualquier otro componente: copiá el archivo
de la pestaña Manual y ajustá los imports. Las animaciones viajan adentro del
archivo como
SPRITE_CSS, así que no hay nada más que copiar — renderizá con
withStyles o inyectá el string una sola vez:1<style>{SPRITE_CSS}</style>
Anatomía
1import { Sprite } from '@/components/ui/sprite';
1<Sprite seed="nachui" />
Semillas
Cualquier string arma un sprite, y el mismo string arma siempre el mismo — así
que un usuario o un email sirven de avatar sin guardar nada.
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}
Estados
idle respira y parpadea, walk corre el ciclo de caminata de dos cuadros,
work saca la laptop, y loop juega todo: llega caminando, se sienta, laburea
y se levanta.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}
Partes
Pasá
parts para pisar lo que eligió la semilla, ranura por ranura. El color es
un eje aparte de la forma: seis peinados y cinco colores son treinta cabezas.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}
Suelto
spriteToSvg devuelve el sprite entero como un string SVG, animaciones
incluidas, así que funciona donde la hoja de estilos de una página no llega — una
respuesta de servidor, un archivo guardado, un README. En este sitio, además,
cada sprite es una URL:1/api/sprite/{semilla}.svg?state=loop&size=96
La misma semilla responde siempre la misma imagen, así que la respuesta se
cachea como inmutable.
Props
| Prop | Tipo | Default | Descripción |
|---|---|---|---|
seed | string | nachui | Cualquier string. El mismo string da el mismo sprite. |
parts | Partial<SpriteParts> | — | Pisa la semilla, ranura por ranura. |
state | bust | idle | walk | work | loop | idle | Qué está haciendo. |
size | number | 48 | Ancho en píxeles; el alto sale de la grilla. |
label | string | - | Convierte al sprite en una imagen con nombre. Sin esto el SVG queda oculto para la tecnología asistiva. |
withStyles | boolean | false | Agrega SPRITE_CSS a la página. Se agrega una sola vez, sin importar cuántos sprites lo pidan. |
Accesibilidad
Por defecto el sprite es decorativo y queda oculto para la tecnología asistiva.
Cuando represente a una persona, poné el nombre al lado en texto, o pasá
label
si el dibujo es lo único que hay y tiene que leerse como imagen.Con movimiento reducido activado, todas las animaciones se detienen y cada estado
queda en una pose quieta. La regla viaja adentro de
SPRITE_CSS, así que los
SVG sueltos también la respetan.¿Encontraste algo que mejorar?
¿Notaste un error, tipografía o detalle faltante en esta página? Ayúdanos a mejorar la documentación abriendo un issue en GitHub.