@dofy/native-emoji-motion 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 — 2026-11-13
4
+
5
+ - Expand the handcrafted collection to 161 emoji and add five reviewed motion families.
6
+ - Add configurable spring and sway motion builders and composable SVG decorations.
7
+ - Support custom families with explicit variant aliases and user-defined priority.
8
+ - Improve the demo with family previews, persistent preferences and clearer examples.
9
+
3
10
  ## 0.1.0 — 2026-11-13
4
11
 
5
12
  Initial public release.
package/README.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  Small, expressive animations for native emoji. A framework independent library with Unicode based automatic classification, reusable motion presets and explicit per emoji overrides.
4
4
 
5
+ ## Collection at a glance
6
+
7
+ The current repository and demo contain **161 handcrafted emoji presets** and **5 reviewed motion families** with **24 additional base emoji**, across **10 categories**. They use **27 reusable motions**; **24 base emoji** have optional SVG effects. Skin tones and other variants are additional sequences, and similar emoji may share the same motion.
8
+
9
+ The default entry also recognizes **5,235 Unicode Emoji 18.0 sequences**, including qualified forms and variants, and applies a category based fallback when there is no handcrafted preset or reviewed family match. These collection counts describe version 0.2.0.
10
+
5
11
  ## Try the demo
6
12
 
7
13
  [Explore the live demo](https://emoji-motion.phpz.org/) or run it locally:
@@ -10,7 +16,7 @@ Small, expressive animations for native emoji. A framework independent library w
10
16
  npm run dev
11
17
  ```
12
18
 
13
- Open http://127.0.0.1:4173. Hover, focus or tap a card; use the filters to explore the groups. The demo imports the same public entry point as an application. Use **Try any emoji** to preview automatic classification. The gallery starts with a curated selection from across the groups. Choose **All presets** for the complete handcrafted list, **With effects** for decorative SVG layers, or select a semantic group. Choose a framework and display mode, then select an emoji to copy its component. The examples show how to place an emoji in a sentence or on its own line.
19
+ Open http://127.0.0.1:4173. Hover, focus or tap a card; use the filters to explore the groups. The demo imports the same public entry point as an application. Use **Try any emoji** to preview automatic classification. The gallery starts with a curated selection from across the groups. Choose **All presets** for the handcrafted list, **Motion families** for reviewed family members, **With effects** for decorative SVG layers, or select a semantic group. Choose a framework and display mode, then select an emoji to copy its component. The examples show how to place an emoji in a sentence or on its own line.
14
20
 
15
21
  ## Installation
16
22
 
@@ -53,6 +59,8 @@ For plain HTML or other frameworks, import `@dofy/native-emoji-motion/element` o
53
59
 
54
60
  React, Vue and Svelte components accept `emoji`, `skinTone`, `display`, `decoration`, `label`, `events`, and a custom `registry`. For a custom interaction target, React accepts `triggerRef` and Vue/Svelte accept `trigger`. Components clean up their listeners when updated or unmounted. A global skin tone set on a registry is read when a component renders; to update an already mounted component, update its `skinTone` prop or cause a framework rerender.
55
61
 
62
+ Components do not set a mouse cursor: the emoji follows its surrounding context. Set `cursor: pointer` on a button or other control when the emoji is part of an action.
63
+
56
64
  ## Low level API
57
65
 
58
66
  Import the package in an ESM application:
@@ -162,6 +170,32 @@ motion.bind(emojiElement, '🐙', { trigger: card });
162
170
 
163
171
  Groups select a default motion; an emoji entry can override it with `motion`, and `variants` lists exact alternate sequences that inherit its settings. References and duplicate variants are validated when a registry is created. Definitions are ordinary JavaScript objects; do not mutate exported built in data. Create a new registry to change configuration. API types are included.
164
172
 
173
+ ### Motion families
174
+
175
+ Families apply a shared motion to explicitly reviewed members. Built in families cover balls, plants, drinks, flying animals and people in poses. They do not assume every emoji in a Unicode subgroup moves alike. `resolve()` reports `source: 'family'` and `familyId` for family matches; `list()` remains the handcrafted emoji list, while `families()` returns family definitions.
176
+
177
+ Use the configurable spring and sway builders to create reusable motions. A family can adjust Web Animations timing with `animation`, and combine named SVG effects with `decorations`:
178
+
179
+ ```js
180
+ import { createEmojiMotion, createSpringMotion } from '@dofy/native-emoji-motion';
181
+
182
+ const motion = createEmojiMotion({
183
+ motions: { smallBounce: createSpringMotion({ height: 14, rebounds: 2, duration: 900 }) },
184
+ families: {
185
+ snacks: {
186
+ label: 'Snacks', group: 'food', motion: 'smallBounce',
187
+ members: ['🍪', '🍩'],
188
+ animation: { duration: 800 },
189
+ decorations: ['impact', 'sparkle'],
190
+ },
191
+ },
192
+ });
193
+ ```
194
+
195
+ Pass `motion` as the component's `registry` property to use this configuration. `createSwayMotion({ angle, duration, origin })` provides a direction neutral sway. Effects remain optional and require a decoration container when using the low level API. Each effect keeps its own anchor unless the caller explicitly overrides it.
196
+
197
+ Custom emoji definitions take priority, followed by custom Unicode classifications and custom families. Built in single emoji presets precede built in families; remaining emoji use automatic classification. A custom group default overrides built in presets and families, while an explicitly defined custom family keeps its chosen motion. A custom family with the same ID replaces the built in family. Unicode variants and unambiguous skin tones are resolved by the full entry; for the lite entry, supply explicit `aliases: { '🧎🏽': '🧎' }` mappings when a family needs variants. Mixed tone sequences are never guessed or rewritten.
198
+
165
199
  ## Optional decorative effects
166
200
 
167
201
  Emoji can also reference a decoration preset. The built in 📡 example adds a neutral signal halo around the whole glyph; 🔔 and 🎵 add notes, and ✨ adds sparkles. Decorations are opt in and render inside a container you provide. Make the container `position: relative` and size it to the glyph. Effects do not change the emoji itself.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dofy/native-emoji-motion",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Expressive, extensible animations for native emoji with optional SVG effects",
5
5
  "type": "module",
6
6
  "exports": {
package/src/index.d.ts CHANGED
@@ -7,15 +7,19 @@ export interface Decoration { anchor: Anchor; layers: DecorationLayer[] }
7
7
  export type SkinTone = 'default' | 'light' | 'medium-light' | 'medium' | 'medium-dark' | 'dark';
8
8
  export interface EmojiEntry { group: string; name: string; motion?: string; decoration?: string; variants?: string[]; skinTones?: Partial<Record<Exclude<SkinTone, 'default'>, string>> }
9
9
  export interface ClassificationRule { group: string; motion?: string }
10
- export interface Config { defaults?: boolean; autoDetect?: boolean; classifications?: { groups?: Record<string, ClassificationRule>; subgroups?: Record<string, ClassificationRule>; fallback?: ClassificationRule }; defaultSkinTone?: SkinTone; motions?: Record<string, Motion>; decorations?: Record<string, Decoration>; groups?: Record<string, Group>; emoji?: Record<string, EmojiEntry> }
10
+ export interface MotionFamily { label: string; group: string; motion: string; members: string[]; aliases?: Record<string, string>; decoration?: string; decorations?: string[]; animation?: KeyframeAnimationOptions }
11
+ export interface Config { defaults?: boolean; autoDetect?: boolean; classifications?: { groups?: Record<string, ClassificationRule>; subgroups?: Record<string, ClassificationRule>; fallback?: ClassificationRule }; defaultSkinTone?: SkinTone; motions?: Record<string, Motion>; decorations?: Record<string, Decoration>; groups?: Record<string, Group>; emoji?: Record<string, EmojiEntry>; families?: Record<string, MotionFamily> }
11
12
  export interface PlayOptions { ignoreReducedMotion?: boolean; skinTone?: SkinTone; animation?: KeyframeAnimationOptions; decoration?: { container: HTMLElement; anchor?: Anchor; flipX?: boolean; rotate?: number } }
12
13
  export interface BindOptions extends PlayOptions { trigger?: EventTarget; events?: string[] }
13
- export interface Resolution extends Omit<EmojiEntry, 'group' | 'motion'> { glyph: string; canonical: string; skinTone: SkinTone; groupId: string; group: Group; motionId: string; motion: Motion; decorationPreset?: Decoration; source: 'user' | 'user-classification' | 'preset' | 'unicode' | 'fallback'; unicodeGroup?: string; unicodeSubgroup?: string }
14
- export interface EmojiMotion { resolve(glyph: string, options?: { skinTone?: SkinTone }): Resolution | undefined; setDefaultSkinTone(tone: SkinTone): void; getDefaultSkinTone(): SkinTone; play(element: Element, glyph: string, options?: PlayOptions): Animation | null; bind(element: Element, glyph: string, options?: BindOptions): () => void; list(): string[]; groups(): (Group & { id: string })[] }
14
+ export interface Resolution extends Omit<EmojiEntry, 'group' | 'motion'> { glyph: string; canonical: string; skinTone: SkinTone; groupId: string; group: Group; motionId: string; motion: Motion; decorationPreset?: Decoration; decorationPresets: Decoration[]; familyId?: string; animation?: KeyframeAnimationOptions; source: 'user' | 'user-classification' | 'preset' | 'family' | 'unicode' | 'fallback'; unicodeGroup?: string; unicodeSubgroup?: string }
15
+ export interface EmojiMotion { resolve(glyph: string, options?: { skinTone?: SkinTone }): Resolution | undefined; setDefaultSkinTone(tone: SkinTone): void; getDefaultSkinTone(): SkinTone; play(element: Element, glyph: string, options?: PlayOptions): Animation | null; bind(element: Element, glyph: string, options?: BindOptions): () => void; list(): string[]; groups(): (Group & { id: string })[]; families(): (MotionFamily & { id: string })[] }
15
16
  export declare function createEmojiMotion(config?: Config): EmojiMotion;
16
17
  export declare const motions: Record<string, Motion>;
17
18
  export declare const groups: Record<string, Group>;
18
19
  export declare const emoji: Record<string, EmojiEntry>;
19
20
  export declare const emojiMotion: EmojiMotion;
20
21
  export declare const decorations: Record<string, Decoration>;
22
+ export declare const families: Record<string, MotionFamily>;
23
+ export declare function createSpringMotion(options?: { height?: number; rebounds?: 0 | 1 | 2; duration?: number }): Motion;
24
+ export declare function createSwayMotion(options?: { angle?: number; duration?: number; origin?: string }): Motion;
21
25
  export declare const unicodeVersion: string;
package/src/index.js CHANGED
@@ -3,6 +3,6 @@ import { lookupUnicode, looksLikeEmoji } from './unicode.js';
3
3
 
4
4
  const classifier = { lookup: lookupUnicode, looksLikeEmoji };
5
5
  export function createEmojiMotion(config = {}) { return createRegistry(config, classifier); }
6
- export { motions, groups, emoji, decorations } from './presets.js';
6
+ export { motions, groups, emoji, decorations, families, createSpringMotion, createSwayMotion } from './presets.js';
7
7
  export { unicodeVersion } from './unicode.js';
8
8
  export const emojiMotion = createEmojiMotion();
package/src/lite.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export { createEmojiMotion, emojiMotion, motions, groups, emoji, decorations } from './index.js';
2
- export type { Motion, Group, Anchor, SvgShape, DecorationLayer, Decoration, SkinTone, EmojiEntry, Config, ClassificationRule, PlayOptions, BindOptions, Resolution, EmojiMotion } from './index.js';
1
+ export { createEmojiMotion, emojiMotion, motions, groups, emoji, decorations, families, createSpringMotion, createSwayMotion } from './index.js';
2
+ export type { Motion, MotionFamily, Group, Anchor, SvgShape, DecorationLayer, Decoration, SkinTone, EmojiEntry, Config, ClassificationRule, PlayOptions, BindOptions, Resolution, EmojiMotion } from './index.js';
package/src/lite.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createRegistry } from './registry.js';
2
2
  /** Preset-only entry: does not load Unicode classification data. */
3
3
  export function createEmojiMotion(config = {}) { return createRegistry(config); }
4
- export { motions, groups, emoji, decorations } from './presets.js';
4
+ export { motions, groups, emoji, decorations, families, createSpringMotion, createSwayMotion } from './presets.js';
5
5
  export const emojiMotion = createEmojiMotion();
package/src/presets.js CHANGED
@@ -1,3 +1,23 @@
1
+ const curve = 'cubic-bezier(.22,1,.36,1)';
2
+ function boundedNumber(name, value, min, max) {
3
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max) throw new RangeError(`${name} must be between ${min} and ${max}`);
4
+ return value;
5
+ }
6
+ /** Build a direction-neutral bounce; height is a percentage of glyph height. */
7
+ export function createSpringMotion({ height = 24, rebounds = 1, duration = 900 } = {}) {
8
+ boundedNumber('height', height, 0, 60); boundedNumber('duration', duration, 1, 10000);
9
+ if (!Number.isInteger(rebounds) || rebounds < 0 || rebounds > 2) throw new RangeError('rebounds must be 0, 1, or 2');
10
+ const frames = [{ transform: 'translateY(0) scale(1)', offset: 0 }, { transform: `translateY(-${height}%) scale(.97,1.03)`, offset: .3 }, { transform: 'translateY(0) scale(1.09,.91)', offset: .6 }];
11
+ if (rebounds >= 1) frames.push({ transform: `translateY(-${height * .3}%) scale(1)`, offset: .78 });
12
+ if (rebounds === 2) frames.push({ transform: 'translateY(0) scale(1)', offset: .88 }, { transform: `translateY(-${height * .12}%) scale(1)`, offset: .94 });
13
+ frames.push({ transform: 'translateY(0) scale(1)', offset: 1 });
14
+ return { keyframes: frames, options: { duration, easing: curve }, origin: '50% 90%' };
15
+ }
16
+ /** Build a balanced sway; no assumption about the native glyph's direction. */
17
+ export function createSwayMotion({ angle = 8, duration = 1200, origin = '50% 90%' } = {}) {
18
+ boundedNumber('angle', angle, 0, 30); boundedNumber('duration', duration, 1, 10000);
19
+ return { keyframes: [{ transform: 'rotate(0deg)' }, { transform: `rotate(-${angle}deg)` }, { transform: `rotate(${angle}deg)` }, { transform: `rotate(-${angle / 2}deg)` }, { transform: 'rotate(0deg)' }], options: { duration, easing: curve }, origin };
20
+ }
1
21
  // Motions transform the entire native glyph. Keyframes deliberately begin and end at rest.
2
22
  export const motions = {
3
23
  glide: { keyframes: [{ transform: 'translate(0, 0) rotate(0deg)' }, { transform: 'translate(-12%, 8%) rotate(-9deg)', offset: .25 }, { transform: 'translate(12%, -22%) rotate(9deg)', offset: .6 }, { transform: 'translate(0, 0) rotate(0deg)' }], options: { duration: 1050, easing: 'cubic-bezier(.22,1,.36,1)' } },
@@ -11,7 +31,22 @@ export const motions = {
11
31
  search: { keyframes: [{ transform: 'translate(0,0) rotate(0deg)' }, { transform: 'translate(-12%,-8%) rotate(-12deg)' }, { transform: 'translate(12%,6%) rotate(12deg)' }, { transform: 'translate(0,0) rotate(0deg)' }], options: { duration: 850, easing: 'cubic-bezier(.22,1,.36,1)' }, origin: '65% 65%' },
12
32
  swing: { keyframes: [{ transform: 'rotate(0deg)' }, { transform: 'rotate(18deg)' }, { transform: 'rotate(-15deg)' }, { transform: 'rotate(9deg)' }, { transform: 'rotate(-4deg)' }, { transform: 'rotate(0deg)' }], options: { duration: 850, easing: 'cubic-bezier(.22,1,.36,1)' }, origin: '50% 10%' },
13
33
  float: { keyframes: [{ transform: 'translateY(0) rotate(0deg)' }, { transform: 'translateY(-18%) rotate(-8deg)', offset: .4 }, { transform: 'translateY(-10%) rotate(6deg)', offset: .7 }, { transform: 'translateY(0) rotate(0deg)' }], options: { duration: 1000, easing: 'cubic-bezier(.22,1,.36,1)' } },
14
- shake: { keyframes: [{ transform: 'translateX(0)' }, { transform: 'translateX(-10%) rotate(-8deg)' }, { transform: 'translateX(10%) rotate(8deg)' }, { transform: 'translateX(-6%) rotate(-5deg)' }, { transform: 'translateX(4%) rotate(3deg)' }, { transform: 'translateX(0)' }], options: { duration: 650, easing: 'cubic-bezier(.22,1,.36,1)' } }
34
+ shake: { keyframes: [{ transform: 'translateX(0)' }, { transform: 'translateX(-10%) rotate(-8deg)' }, { transform: 'translateX(10%) rotate(8deg)' }, { transform: 'translateX(-6%) rotate(-5deg)' }, { transform: 'translateX(4%) rotate(3deg)' }, { transform: 'translateX(0)' }], options: { duration: 650, easing: 'cubic-bezier(.22,1,.36,1)' } },
35
+ hop: { keyframes: [{ transform: 'translateY(0) scale(1)' }, { transform: 'translateY(-28%) scale(1.04,.96)', offset: .32 }, { transform: 'translateY(0) scale(1.1,.9)', offset: .64 }, { transform: 'translateY(-9%) scale(1)', offset: .8 }, { transform: 'translateY(0) scale(1)' }], options: { duration: 850, easing: 'cubic-bezier(.22,1,.36,1)' }, origin: '50% 90%' },
36
+ flutter: { keyframes: [{ transform: 'translateY(0) rotate(0deg)' }, { transform: 'translateY(-12%) rotate(-8deg)' }, { transform: 'translateY(-21%) rotate(8deg)' }, { transform: 'translateY(-10%) rotate(-5deg)' }, { transform: 'translateY(0) rotate(0deg)' }], options: { duration: 900, easing: 'cubic-bezier(.22,1,.36,1)' } },
37
+ sway: { keyframes: [{ transform: 'rotate(0deg)' }, { transform: 'rotate(-9deg)' }, { transform: 'rotate(8deg)' }, { transform: 'rotate(-5deg)' }, { transform: 'rotate(0deg)' }], options: { duration: 1100, easing: 'cubic-bezier(.22,1,.36,1)' }, origin: '50% 95%' },
38
+ flip: { keyframes: [{ transform: 'rotate(0deg)' }, { transform: 'rotate(185deg)', offset: .5 }, { transform: 'rotate(360deg)' }], options: { duration: 1000, easing: 'cubic-bezier(.22,1,.36,1)' } },
39
+ sip: { keyframes: [{ transform: 'translateY(0) rotate(0deg)' }, { transform: 'translateY(-8%) rotate(-6deg)', offset: .35 }, { transform: 'translateY(-8%) rotate(6deg)', offset: .65 }, { transform: 'translateY(0) rotate(0deg)' }], options: { duration: 1000, easing: 'cubic-bezier(.22,1,.36,1)' }, origin: '50% 80%' },
40
+ twirl: { keyframes: [{ transform: 'rotate(0deg)' }, { transform: 'rotate(-10deg)' }, { transform: 'rotate(12deg)' }, { transform: 'rotate(-6deg)' }, { transform: 'rotate(0deg)' }], options: { duration: 950, easing: 'cubic-bezier(.22,1,.36,1)' }, origin: '50% 80%' },
41
+ nod: { keyframes: [{ transform: 'rotate(0deg)' }, { transform: 'rotate(8deg)', offset: .3 }, { transform: 'rotate(-4deg)', offset: .6 }, { transform: 'rotate(0deg)' }], options: { duration: 850, easing: 'cubic-bezier(.22,1,.36,1)' }, origin: '50% 85%' },
42
+ pop: { keyframes: [{ transform: 'scale(1)' }, { transform: 'scale(.85)', offset: .2 }, { transform: 'scale(1.18)', offset: .48 }, { transform: 'scale(.97)', offset: .75 }, { transform: 'scale(1)' }], options: { duration: 750, easing: 'cubic-bezier(.22,1,.36,1)' } },
43
+ bob: { keyframes: [{ transform: 'translateY(0) rotate(0deg)' }, { transform: 'translateY(-12%) rotate(-4deg)', offset: .3 }, { transform: 'translateY(4%) rotate(4deg)', offset: .65 }, { transform: 'translateY(0) rotate(0deg)' }], options: { duration: 1000, easing: 'cubic-bezier(.22,1,.36,1)' } },
44
+ breathe: { keyframes: [{ transform: 'scale(1)' }, { transform: 'scale(1.09,1.05)', offset: .48 }, { transform: 'scale(1)' }], options: { duration: 1200, easing: 'cubic-bezier(.22,1,.36,1)' } },
45
+ spring: createSpringMotion(),
46
+ leafSway: createSwayMotion(),
47
+ hoverFlight: { keyframes: [{ transform: 'translateY(0) rotate(0deg)' }, { transform: 'translateY(-15%) rotate(-6deg)', offset: .3 }, { transform: 'translateY(-9%) rotate(6deg)', offset: .6 }, { transform: 'translateY(0) rotate(0deg)' }], options: { duration: 1000, easing: 'cubic-bezier(.22,1,.36,1)' } },
48
+ gentleSip: { keyframes: [{ transform: 'translateY(0) rotate(0deg)' }, { transform: 'translateY(-7%) rotate(-4deg)', offset: .35 }, { transform: 'translateY(-7%) rotate(4deg)', offset: .65 }, { transform: 'translateY(0) rotate(0deg)' }], options: { duration: 1000, easing: 'cubic-bezier(.22,1,.36,1)' }, origin: '50% 85%' },
49
+ pose: { keyframes: [{ transform: 'scale(1) translateY(0)' }, { transform: 'scale(1.04,1.06) translateY(-3%)', offset: .45 }, { transform: 'scale(1) translateY(0)' }], options: { duration: 1100, easing: 'cubic-bezier(.22,1,.36,1)' }, origin: '50% 90%' }
15
50
  };
16
51
 
17
52
  // Groups describe semantic families; emoji entries may override the group's motion.
@@ -32,6 +67,17 @@ export const groups = {
32
67
 
33
68
  // Explicit mappings avoid guessing at modifier placement in complex sequences.
34
69
  const skinTones = glyph => Object.fromEntries(['light', 'medium-light', 'medium', 'medium-dark', 'dark'].map((tone, index) => [tone, glyph + String.fromCodePoint(0x1f3fb + index)]));
70
+ // A person modifier belongs before the joiner, never at the end of the sequence.
71
+ const personSkinTones = suffix => Object.fromEntries(['light', 'medium-light', 'medium', 'medium-dark', 'dark'].map((tone, index) => [tone, `🧑${String.fromCodePoint(0x1f3fb + index)}‍${suffix}`]));
72
+ const familyToneAliases = glyphs => Object.fromEntries(glyphs.flatMap(glyph => Object.values(skinTones(glyph)).map(variant => [variant, glyph])));
73
+ // Members are reviewed explicitly; no entire Unicode subgroup is assumed to move alike.
74
+ export const families = {
75
+ balls: { label: 'Balls', group: 'activities', motion: 'spring', members: ['⚾', '🥎', '🎱', '🎳'], animation: { duration: 900 }, decorations: ['impact', 'sparkle'] },
76
+ plants: { label: 'Plants', group: 'nature', motion: 'leafSway', members: ['🌷', '🌹', '🌺', '🪻', '🌴', '🌿', '🍀'] },
77
+ drinks: { label: 'Drinks', group: 'food', motion: 'gentleSip', members: ['🥤', '🧃', '🧋', '🍹', '🍸'] },
78
+ flying: { label: 'Flying animals', group: 'nature', motion: 'hoverFlight', members: ['🦜', '🦉', '🦅', '🪰', '🦟'] },
79
+ poses: { label: 'People in poses', group: 'people', motion: 'pose', members: ['🧎', '🧍', '🙇'], aliases: familyToneAliases(['🧎', '🧍', '🙇']) }
80
+ };
35
81
  // Variants are explicit: unrelated sequences are never silently normalized.
36
82
  export const emoji = {
37
83
  '👋': { group: 'gestures', name: 'Waving hand', skinTones: skinTones('👋') },
@@ -58,11 +104,48 @@ export const emoji = {
58
104
  '💚': { group: 'hearts', name: 'Green heart' }, '💙': { group: 'hearts', name: 'Blue heart' }, '💜': { group: 'hearts', name: 'Purple heart' }, '💔': { group: 'hearts', name: 'Broken heart', motion: 'shake' },
59
105
  '🔍': { group: 'objects', name: 'Magnifying glass', motion: 'search' }, '🔔': { group: 'objects', name: 'Bell', motion: 'swing', decoration: 'sound' }, '🎁': { group: 'objects', name: 'Gift', motion: 'shake' }, '📡': { group: 'objects', name: 'Satellite antenna', motion: 'wobble', decoration: 'signal' }, '⏱️': { group: 'objects', name: 'Stopwatch', motion: 'swing', variants: ['⏱'] },
60
106
  '💡': { group: 'objects', name: 'Light bulb', motion: 'pulse' }, '📷': { group: 'objects', name: 'Camera', motion: 'bounce' }, '🔑': { group: 'objects', name: 'Key', motion: 'wobble' }, '📌': { group: 'objects', name: 'Pushpin', motion: 'bounce' }, '📦': { group: 'objects', name: 'Package', motion: 'shake' }, '🧭': { group: 'objects', name: 'Compass', motion: 'spin' },
61
- '🌸': { group: 'nature', name: 'Blossom' }, '🍃': { group: 'nature', name: 'Leaves' }, '☀️': { group: 'nature', name: 'Sun', motion: 'spin', variants: ['☀'] },
107
+ '🌸': { group: 'nature', name: 'Blossom', decoration: 'petals' }, '🍃': { group: 'nature', name: 'Leaves' }, '☀️': { group: 'nature', name: 'Sun', motion: 'spin', variants: ['☀'] },
62
108
  '🌻': { group: 'nature', name: 'Sunflower', motion: 'swing' }, '🌈': { group: 'nature', name: 'Rainbow', motion: 'float' }, '🌙': { group: 'nature', name: 'Crescent moon', motion: 'float' }, '⭐': { group: 'nature', name: 'Star', motion: 'pulse', decoration: 'sparkle' }, '🔥': { group: 'nature', name: 'Fire', motion: 'wobble' }, '🌊': { group: 'nature', name: 'Water wave', motion: 'float' },
63
109
  '🚀': { group: 'travel', name: 'Rocket' }, '✈️': { group: 'travel', name: 'Airplane', motion: 'glide', variants: ['✈'] }, '🚲': { group: 'travel', name: 'Bicycle', motion: 'ride' },
64
110
  '🚗': { group: 'travel', name: 'Car', motion: 'ride' }, '🚂': { group: 'travel', name: 'Locomotive', motion: 'ride' }, '🛸': { group: 'travel', name: 'Flying saucer', motion: 'float' }, '🚢': { group: 'travel', name: 'Ship', motion: 'wobble' },
65
- '✨': { group: 'symbols', name: 'Sparkles', motion: 'pulse', decoration: 'sparkle' }, '⚙️': { group: 'symbols', name: 'Gear', variants: ['⚙'] }, '🎯': { group: 'symbols', name: 'Target', motion: 'bounce' }, '🎵': { group: 'symbols', name: 'Musical note', motion: 'swing', decoration: 'sound' }, '💤': { group: 'symbols', name: 'Sleeping symbol', motion: 'float' }, '💥': { group: 'symbols', name: 'Collision', motion: 'pulse' }, '❗': { group: 'symbols', name: 'Exclamation mark', motion: 'bounce' }, '❓': { group: 'symbols', name: 'Question mark', motion: 'wobble' }, '✅': { group: 'symbols', name: 'Check mark', motion: 'bounce' }
111
+ '✨': { group: 'symbols', name: 'Sparkles', motion: 'pulse', decoration: 'sparkle' }, '⚙️': { group: 'symbols', name: 'Gear', variants: ['⚙'] }, '🎯': { group: 'symbols', name: 'Target', motion: 'bounce' }, '🎵': { group: 'symbols', name: 'Musical note', motion: 'swing', decoration: 'sound' }, '💤': { group: 'symbols', name: 'Sleeping symbol', motion: 'float' }, '💥': { group: 'symbols', name: 'Collision', motion: 'pulse' }, '❗': { group: 'symbols', name: 'Exclamation mark', motion: 'bounce' }, '❓': { group: 'symbols', name: 'Question mark', motion: 'wobble' }, '✅': { group: 'symbols', name: 'Check mark', motion: 'bounce' },
112
+ '🐰': { group: 'nature', name: 'Rabbit face', motion: 'hop' }, '🦋': { group: 'nature', name: 'Butterfly', motion: 'flutter' }, '🐝': { group: 'nature', name: 'Bee', motion: 'flutter' }, '🌱': { group: 'nature', name: 'Seedling', motion: 'sway' }, '🌧️': { group: 'nature', name: 'Cloud with rain', motion: 'float', decoration: 'rain', variants: ['🌧'] },
113
+ '☕': { group: 'food', name: 'Hot beverage', motion: 'sip', decoration: 'steam' }, '🍵': { group: 'food', name: 'Teacup', motion: 'sip', decoration: 'steam' }, '🍕': { group: 'food', name: 'Pizza', motion: 'bounce' }, '🍦': { group: 'food', name: 'Soft ice cream', motion: 'sway' },
114
+ '🏀': { group: 'activities', name: 'Basketball', motion: 'hop' }, '⚽': { group: 'activities', name: 'Soccer ball', motion: 'hop' }, '🎈': { group: 'activities', name: 'Balloon', motion: 'float' }, '🎉': { group: 'activities', name: 'Party popper', motion: 'bounce', decoration: 'sparkle' },
115
+ '💃': { group: 'people', name: 'Woman dancing', motion: 'twirl', skinTones: skinTones('💃') }, '🕺': { group: 'people', name: 'Man dancing', motion: 'twirl', skinTones: skinTones('🕺') },
116
+ '⏳': { group: 'objects', name: 'Hourglass', motion: 'flip' }, '⏰': { group: 'objects', name: 'Alarm clock', motion: 'shake' }, '🕯️': { group: 'objects', name: 'Candle', motion: 'sway', variants: ['🕯'] },
117
+
118
+ // Additional handcrafted entries share motions where their physical action fits.
119
+ '😀': { group: 'faces', name: 'Grinning face', motion: 'bounce' }, '🤣': { group: 'faces', name: 'Rolling on the floor laughing', motion: 'shake' },
120
+ '🤩': { group: 'faces', name: 'Star struck', motion: 'pop', decoration: 'sparkle' }, '🥺': { group: 'faces', name: 'Pleading face', motion: 'breathe' },
121
+ '😮': { group: 'faces', name: 'Face with open mouth', motion: 'pop' }, '😡': { group: 'faces', name: 'Angry face', motion: 'shake' },
122
+ '🤗': { group: 'faces', name: 'Hugging face', motion: 'breathe' }, '🤯': { group: 'faces', name: 'Exploding head', motion: 'pop', decoration: 'sparkle' },
123
+ '✌️': { group: 'gestures', name: 'Victory hand', motion: 'twirl', variants: ['✌'], skinTones: skinTones('✌') },
124
+ '🤟': { group: 'gestures', name: 'Love you gesture', motion: 'wave', skinTones: skinTones('🤟') }, '👊': { group: 'gestures', name: 'Oncoming fist', motion: 'pop', skinTones: skinTones('👊') },
125
+ '🤜': { group: 'gestures', name: 'Right facing fist', motion: 'nod', skinTones: skinTones('🤜') }, '🤛': { group: 'gestures', name: 'Left facing fist', motion: 'nod', skinTones: skinTones('🤛') },
126
+ '🫰': { group: 'gestures', name: 'Hand with index finger and thumb crossed', motion: 'pop', skinTones: skinTones('🫰') },
127
+ '🐱': { group: 'nature', name: 'Cat face', motion: 'nod' }, '🐻': { group: 'nature', name: 'Bear', motion: 'nod' }, '🐼': { group: 'nature', name: 'Panda', motion: 'sway' },
128
+ '🐸': { group: 'nature', name: 'Frog', motion: 'hop' }, '🐧': { group: 'nature', name: 'Penguin', motion: 'sway' }, '🐥': { group: 'nature', name: 'Front facing baby chick', motion: 'hop' },
129
+ '🐢': { group: 'nature', name: 'Turtle', motion: 'bob' }, '🐟': { group: 'nature', name: 'Fish', motion: 'bob' }, '🐬': { group: 'nature', name: 'Dolphin', motion: 'bob' },
130
+ '🐙': { group: 'nature', name: 'Octopus', motion: 'twirl' }, '🌵': { group: 'nature', name: 'Cactus', motion: 'sway' }, '🌲': { group: 'nature', name: 'Evergreen tree', motion: 'sway' },
131
+ '🌼': { group: 'nature', name: 'Blossom flower', motion: 'sway', decoration: 'petals' }, '❄️': { group: 'nature', name: 'Snowflake', motion: 'float', variants: ['❄'] }, '☁️': { group: 'nature', name: 'Cloud', motion: 'float', variants: ['☁'] },
132
+ '🍎': { group: 'food', name: 'Red apple', motion: 'nod' }, '🍉': { group: 'food', name: 'Watermelon', motion: 'wobble' }, '🍓': { group: 'food', name: 'Strawberry', motion: 'bounce' },
133
+ '🍌': { group: 'food', name: 'Banana', motion: 'wobble' }, '🥑': { group: 'food', name: 'Avocado', motion: 'nod' }, '🍰': { group: 'food', name: 'Shortcake', motion: 'bounce' },
134
+ '🍩': { group: 'food', name: 'Doughnut', motion: 'spin' }, '🍿': { group: 'food', name: 'Popcorn', motion: 'pop' }, '🍪': { group: 'food', name: 'Cookie', motion: 'wobble' },
135
+ '🍜': { group: 'food', name: 'Steaming bowl', motion: 'sip', decoration: 'steam' }, '🫖': { group: 'food', name: 'Teapot', motion: 'sip', decoration: 'steam' },
136
+ '🏐': { group: 'activities', name: 'Volleyball', motion: 'hop' }, '🎾': { group: 'activities', name: 'Tennis', motion: 'hop' }, '🏓': { group: 'activities', name: 'Ping pong', motion: 'bounce' },
137
+ '🎨': { group: 'activities', name: 'Artist palette', motion: 'twirl' }, '🎸': { group: 'activities', name: 'Guitar', motion: 'sway', decoration: 'sound' },
138
+ '🥁': { group: 'activities', name: 'Drum', motion: 'shake', decoration: 'sound' }, '🎮': { group: 'activities', name: 'Game controller', motion: 'wobble' }, '🪁': { group: 'activities', name: 'Kite', motion: 'flutter' },
139
+ '🙋': { group: 'people', name: 'Person raising hand', motion: 'wave', skinTones: skinTones('🙋') }, '🧘': { group: 'people', name: 'Person in lotus position', motion: 'breathe', skinTones: skinTones('🧘') },
140
+ '🏃': { group: 'people', name: 'Person running', motion: 'hop', skinTones: skinTones('🏃') }, '🤸': { group: 'people', name: 'Person cartwheeling', motion: 'twirl', skinTones: skinTones('🤸') },
141
+ '🏊': { group: 'people', name: 'Person swimming', motion: 'bob', skinTones: skinTones('🏊') }, '🏄': { group: 'people', name: 'Person surfing', motion: 'bob', skinTones: skinTones('🏄') },
142
+ '🧑‍🍳': { group: 'people', name: 'Cook', motion: 'nod', skinTones: personSkinTones('🍳') }, '🧑‍🎨': { group: 'people', name: 'Artist', motion: 'twirl', skinTones: personSkinTones('🎨') },
143
+ '🧑‍🚀': { group: 'people', name: 'Astronaut', motion: 'float', skinTones: personSkinTones('🚀') }, '🧑‍💻': { group: 'people', name: 'Technologist', motion: 'nod', skinTones: personSkinTones('💻') },
144
+ '📱': { group: 'objects', name: 'Mobile phone', motion: 'shake' }, '📚': { group: 'objects', name: 'Books', motion: 'nod' }, '✉️': { group: 'objects', name: 'Envelope', motion: 'bob', variants: ['✉'] },
145
+ '💌': { group: 'objects', name: 'Love letter', motion: 'bob', decoration: 'sparkle' }, '🔒': { group: 'objects', name: 'Locked', motion: 'wobble' }, '🔓': { group: 'objects', name: 'Unlocked', motion: 'pop' },
146
+ '🔦': { group: 'objects', name: 'Flashlight', motion: 'nod' }, '🪄': { group: 'objects', name: 'Magic wand', motion: 'twirl', decoration: 'sparkle' }, '🎤': { group: 'objects', name: 'Microphone', motion: 'swing', decoration: 'sound' },
147
+ '🚌': { group: 'travel', name: 'Bus', motion: 'ride' }, '🚕': { group: 'travel', name: 'Taxi', motion: 'ride' }, '🚁': { group: 'travel', name: 'Helicopter', motion: 'bob' },
148
+ '🎡': { group: 'travel', name: 'Ferris wheel', motion: 'spin' }, '⛵': { group: 'travel', name: 'Sailboat', motion: 'bob' }, '🎠': { group: 'travel', name: 'Carousel horse', motion: 'hop' }
66
149
  };
67
150
 
68
151
  // Declarative SVG layers never alter the native glyph. Anchors are relative to
@@ -77,7 +160,27 @@ const sparkleShape = { viewBox: '0 0 100 100', elements: [
77
160
  { tag: 'path', attrs: { d: 'M80 58c1 6 2 7 8 8-6 1-7 2-8 8-1-6-2-7-8-8 6-1 7-2 8-8z', fill: '#d787ad' } }
78
161
  ] };
79
162
  const centered = { width: '160%', height: '160%', overflow: 'visible' };
163
+ const steamShape = { viewBox: '0 0 100 100', elements: [
164
+ { tag: 'path', attrs: { d: 'M38 30c-5-5 5-8 0-14M50 27c-5-5 5-8 0-14M62 30c-5-5 5-8 0-14', fill: 'none', stroke: '#a29aa5', 'stroke-width': '2.5', 'stroke-linecap': 'round' } }
165
+ ] };
166
+ const rainShape = { viewBox: '0 0 100 100', elements: [
167
+ { tag: 'path', attrs: { d: 'M30 67v6M50 72v6M70 67v6', fill: 'none', stroke: '#7c9fca', 'stroke-width': '3', 'stroke-linecap': 'round' } }
168
+ ] };
80
169
  export const decorations = {
170
+ impact: { anchor: { x: '50%', y: '50%' }, layers: [{ svg: { viewBox: '0 0 100 100', elements: [{ tag: 'path', attrs: { d: 'M28 75l-5 4M72 75l5 4M50 79v6', fill: 'none', stroke: '#d49b70', 'stroke-width': '3', 'stroke-linecap': 'round' } }] }, style: centered,
171
+ keyframes: [{ opacity: 0, transform: 'translate(-50%, -50%) scale(.8)' }, { opacity: 0, transform: 'translate(-50%, -50%) scale(.8)', offset: .45 }, { opacity: .85, transform: 'translate(-50%, -50%) scale(1)', offset: .65 }, { opacity: 0, transform: 'translate(-50%, -50%) scale(1.1)' }], options: { duration: 900, easing: 'cubic-bezier(.22,1,.36,1)' }
172
+ }] },
173
+ petals: { anchor: { x: '50%', y: '50%' }, layers: [{ svg: { viewBox: '0 0 100 100', elements: [{ tag: 'ellipse', attrs: { cx: '24', cy: '33', rx: '3', ry: '5', fill: '#d89cb9' } }, { tag: 'ellipse', attrs: { cx: '76', cy: '38', rx: '3', ry: '5', fill: '#d89cb9' } }] }, style: centered,
174
+ keyframes: [{ opacity: 0, transform: 'translate(-50%, -58%) rotate(-8deg)' }, { opacity: .8, transform: 'translate(-50%, -50%) rotate(0deg)', offset: .35 }, { opacity: 0, transform: 'translate(-50%, -35%) rotate(8deg)' }], options: { duration: 1200, easing: 'cubic-bezier(.22,1,.36,1)' }
175
+ }] },
176
+ steam: { anchor: { x: '50%', y: '50%' }, layers: [{ svg: steamShape, style: centered,
177
+ keyframes: [{ opacity: 0, transform: 'translate(-50%, -48%)' }, { opacity: .9, transform: 'translate(-50%, -54%)', offset: .4 }, { opacity: 0, transform: 'translate(-50%, -65%)' }],
178
+ options: { duration: 1000, easing: 'cubic-bezier(.22,1,.36,1)' }
179
+ }] },
180
+ rain: { anchor: { x: '50%', y: '50%' }, layers: [{ svg: rainShape, style: centered,
181
+ keyframes: [{ opacity: 0, transform: 'translate(-50%, -55%)' }, { opacity: .9, transform: 'translate(-50%, -50%)', offset: .3 }, { opacity: 0, transform: 'translate(-50%, -40%)' }],
182
+ options: { duration: 950, easing: 'cubic-bezier(.22,1,.36,1)' }
183
+ }] },
81
184
  signal: {
82
185
  anchor: { x: '50%', y: '50%' },
83
186
  layers: [0, 180, 360].map(delay => ({
package/src/registry.js CHANGED
@@ -1,4 +1,4 @@
1
- import { motions as defaultMotions, groups as defaultGroups, emoji as defaultEmoji, decorations as defaultDecorations } from './presets.js';
1
+ import { motions as defaultMotions, groups as defaultGroups, emoji as defaultEmoji, decorations as defaultDecorations, families as defaultFamilies } from './presets.js';
2
2
 
3
3
  const toneNames = ['default', 'light', 'medium-light', 'medium', 'medium-dark', 'dark'];
4
4
  function validateTone(tone) { if (!toneNames.includes(tone)) throw new Error(`Unknown skin tone "${tone}"`); }
@@ -25,6 +25,8 @@ export function createRegistry(config = {}, classifier) {
25
25
  const builtin = useDefaults ? defaultEmoji : {};
26
26
  const user = config.emoji ?? {};
27
27
  const entries = { ...builtin, ...user };
28
+ const userFamilies = config.families ?? {};
29
+ const builtinFamilies = useDefaults ? Object.fromEntries(Object.entries(defaultFamilies).filter(([id]) => !Object.hasOwn(userFamilies, id))) : {};
28
30
  if (config.autoDetect === true && !classifier) throw new Error('Automatic classification is not included in the lite entry');
29
31
  const autoDetect = (config.autoDetect ?? useDefaults) && Boolean(classifier);
30
32
  const rules = config.classifications ?? {};
@@ -45,6 +47,32 @@ export function createRegistry(config = {}, classifier) {
45
47
  return result;
46
48
  }
47
49
  const builtinAliases = makeAliases(builtin); const userAliases = makeAliases(user);
50
+ function makeFamilyIndex(definitions) {
51
+ const index = new Map();
52
+ for (const [id, family] of Object.entries(definitions)) {
53
+ if (!groups[family.group]) throw new Error(`Unknown group "${family.group}" for family ${id}`);
54
+ if (!motions[family.motion]) throw new Error(`Unknown motion "${family.motion}" for family ${id}`);
55
+ if (family.decoration && !decorations[family.decoration]) throw new Error(`Unknown decoration "${family.decoration}" for family ${id}`);
56
+ if (family.decorations && !Array.isArray(family.decorations)) throw new Error(`Family ${id} decorations must be an array`);
57
+ for (const decoration of family.decorations ?? []) if (!decorations[decoration]) throw new Error(`Unknown decoration "${decoration}" for family ${id}`);
58
+ if (!Array.isArray(family.members) || !family.members.length) throw new Error(`Family ${id} needs members`);
59
+ const toneMaps = new Map();
60
+ for (const [alias, base] of Object.entries(family.aliases ?? {})) {
61
+ if (!family.members.includes(base)) throw new Error(`Unknown family member "${base}" for alias ${alias}`);
62
+ const modifiers = [...alias].filter(char => { const code = char.codePointAt(0); return code >= 0x1f3fb && code <= 0x1f3ff; });
63
+ if (modifiers.length === 1) {
64
+ const map = toneMaps.get(base) ?? {}; map[toneNames[modifiers[0].codePointAt(0) - 0x1f3fb + 1]] = alias; toneMaps.set(base, map);
65
+ }
66
+ }
67
+ for (const [glyph, base] of [...family.members.map(glyph => [glyph, glyph]), ...Object.entries(family.aliases ?? {})]) {
68
+ if (typeof glyph !== 'string' || !glyph || index.has(glyph)) throw new Error(`Duplicate or invalid family member: ${glyph}`);
69
+ index.set(glyph, { id, family, base, skinTones: toneMaps.get(base) });
70
+ }
71
+ }
72
+ return index;
73
+ }
74
+ const builtinFamilyIndex = makeFamilyIndex(builtinFamilies);
75
+ const userFamilyIndex = makeFamilyIndex(userFamilies);
48
76
  for (const [id, group] of Object.entries(groups)) if (!motions[group.motion]) throw new Error(`Unknown motion "${group.motion}" for group ${id}`);
49
77
  for (const rule of [...Object.values(rules.groups ?? {}), ...Object.values(rules.subgroups ?? {}), ...(rules.fallback ? [rules.fallback] : [])]) {
50
78
  if (!groups[rule.group]) throw new Error(`Unknown classification group "${rule.group}"`);
@@ -61,11 +89,19 @@ export function createRegistry(config = {}, classifier) {
61
89
  let canonical = candidates.find(key => Object.hasOwn(user, key));
62
90
  let source = canonical ? 'user' : undefined;
63
91
  const userRule = info && (rules.subgroups?.[info.unicodeSubgroup] ?? rules.groups?.[info.unicodeGroup]);
92
+ const familyKeys = [requestedVariant, glyph, info?.canonical].filter(Boolean);
93
+ const findFamily = index => familyKeys.map(key => index.get(key)).find(Boolean);
94
+ const userFamily = !canonical && !userRule ? findFamily(userFamilyIndex) : undefined;
64
95
  // A custom emoji wins first, followed by custom classification rules.
65
- if (!canonical && !userRule) { canonical = candidates.find(key => Object.hasOwn(builtin, key)); if (canonical) source = 'preset'; }
96
+ if (!canonical && !userRule && !userFamily) { canonical = candidates.find(key => Object.hasOwn(builtin, key)); if (canonical) source = 'preset'; }
97
+ const familyMatch = !canonical && !userRule ? (userFamily ?? findFamily(builtinFamilyIndex)) : undefined;
66
98
  let entry;
67
99
  let autoRule;
68
- if (canonical) entry = source === 'user' ? user[canonical] : builtin[canonical];
100
+ if (familyMatch) {
101
+ canonical = familyMatch.base; source = 'family';
102
+ const family = familyMatch.family;
103
+ entry = { group: family.group, name: info?.name ?? family.label, motion: family.motion, decoration: family.decoration, decorations: family.decorations, skinTones: familyMatch.skinTones, familyId: familyMatch.id, animation: family.animation };
104
+ } else if (canonical) entry = source === 'user' ? user[canonical] : builtin[canonical];
69
105
  else if (info) {
70
106
  canonical = info.canonical; source = userRule ? 'user-classification' : 'unicode';
71
107
  autoRule = userRule ?? defaultClassification(info);
@@ -80,7 +116,8 @@ export function createRegistry(config = {}, classifier) {
80
116
  if (!group) throw new Error(`Unknown classification group "${entry.group}"`);
81
117
  // An explicitly supplied group default outranks handcrafted preset motions.
82
118
  // A user-defined emoji or classification remains more specific.
83
- const motionId = source === 'preset' && Object.hasOwn(config.groups ?? {}, entry.group) ? group.motion : entry.motion ?? group.motion;
119
+ const groupOverride = (source === 'preset' || (source === 'family' && !userFamily)) && Object.hasOwn(config.groups ?? {}, entry.group);
120
+ const motionId = groupOverride ? group.motion : entry.motion ?? group.motion;
84
121
  if (!motions[motionId]) throw new Error(`Unknown classification motion "${motionId}"`);
85
122
  const mappedTones = entry.skinTones ?? info?.skinTones;
86
123
  const explicitTone = entry.skinTones ? Object.entries(entry.skinTones).find(([, variant]) => variant === glyph)?.[0] : info?.tone;
@@ -91,7 +128,9 @@ export function createRegistry(config = {}, classifier) {
91
128
  if (skinTone !== 'default') renderedGlyph = mappedTones[skinTone] ?? glyph;
92
129
  else if (explicitTone) renderedGlyph = canonical;
93
130
  }
94
- return { ...entry, glyph: renderedGlyph, canonical, skinTone, groupId: entry.group, group, motionId, motion: motions[motionId], decorationPreset: entry.decoration ? decorations[entry.decoration] : undefined, source, ...(info ? { unicodeGroup: info.unicodeGroup, unicodeSubgroup: info.unicodeSubgroup } : {}) };
131
+ const motion = entry.animation && !groupOverride ? { ...motions[motionId], options: { ...motions[motionId].options, ...entry.animation } } : motions[motionId];
132
+ const decorationPresets = [entry.decoration, ...(entry.decorations ?? [])].filter(Boolean).map(name => decorations[name]);
133
+ return { ...entry, glyph: renderedGlyph, canonical, skinTone, groupId: entry.group, group, motionId, motion, decorationPreset: decorationPresets[0], decorationPresets, source, ...(info ? { unicodeGroup: info.unicodeGroup, unicodeSubgroup: info.unicodeSubgroup } : {}) };
95
134
  }
96
135
  function play(element, glyph, options = {}) {
97
136
  const result = resolve(glyph, { skinTone: options.skinTone });
@@ -102,16 +141,17 @@ export function createRegistry(config = {}, classifier) {
102
141
  // transformOrigin belongs in keyframes so it never mutates the host element's styles.
103
142
  const keyframes = motion.keyframes.map(frame => motion.origin ? { ...frame, transformOrigin: motion.origin } : { ...frame });
104
143
  const animation = element.animate(keyframes, { ...motion.options, ...options.animation });
105
- let decorationRoot;
144
+ const decorationRoots = [];
106
145
  const decorationAnimations = [];
107
146
  const decorationOptions = options.decoration;
108
- const preset = result.decorationPreset;
109
- if (decorationOptions && preset) {
147
+ if (decorationOptions) for (const preset of result.decorationPresets) {
110
148
  const { container, anchor = preset.anchor, flipX = false, rotate = 0 } = decorationOptions;
111
- decorationRoot = container.ownerDocument.createElement('span');
149
+ const decorationRoot = container.ownerDocument.createElement('span');
150
+ decorationRoots.push(decorationRoot);
112
151
  decorationRoot.setAttribute('aria-hidden', 'true');
113
152
  Object.assign(decorationRoot.style, { position: 'absolute', pointerEvents: 'none', left: anchor.x, top: anchor.y, width: '100%', height: '100%', transform: `scaleX(${flipX ? -1 : 1}) rotate(${rotate}deg)` });
114
153
  container.appendChild(decorationRoot);
154
+ const start = decorationAnimations.length;
115
155
  for (const layer of preset.layers) {
116
156
  const node = layer.svg ? container.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'svg') : container.ownerDocument.createElement('span');
117
157
  if (layer.svg) {
@@ -126,9 +166,10 @@ export function createRegistry(config = {}, classifier) {
126
166
  decorationRoot.appendChild(node);
127
167
  decorationAnimations.push(node.animate(layer.keyframes, layer.options));
128
168
  }
129
- Promise.allSettled(decorationAnimations.map(item => item.finished)).then(() => decorationRoot.remove());
169
+ const layerAnimations = decorationAnimations.slice(start);
170
+ Promise.allSettled(layerAnimations.map(item => item.finished)).then(() => decorationRoot.remove());
130
171
  }
131
- active.set(element, { dispose() { animation.cancel(); decorationAnimations.forEach(item => item.cancel()); decorationRoot?.remove(); } });
172
+ active.set(element, { dispose() { animation.cancel(); decorationAnimations.forEach(item => item.cancel()); decorationRoots.forEach(root => root.remove()); } });
132
173
  return animation;
133
174
  }
134
175
  function bind(element, glyph, options = {}) {
@@ -138,5 +179,5 @@ export function createRegistry(config = {}, classifier) {
138
179
  for (const event of events) trigger.addEventListener(event, handler);
139
180
  return () => { for (const event of events) trigger.removeEventListener(event, handler); active.get(element)?.dispose(); active.delete(element); };
140
181
  }
141
- return { resolve, play, bind, setDefaultSkinTone(tone) { validateTone(tone); defaultSkinTone = tone; }, getDefaultSkinTone: () => defaultSkinTone, list: () => Object.keys(entries), groups: () => Object.entries(groups).map(([id, group]) => ({ id, ...group })) };
182
+ return { resolve, play, bind, setDefaultSkinTone(tone) { validateTone(tone); defaultSkinTone = tone; }, getDefaultSkinTone: () => defaultSkinTone, list: () => Object.keys(entries), groups: () => Object.entries(groups).map(([id, group]) => ({ id, ...group })), families: () => Object.entries({ ...builtinFamilies, ...userFamilies }).map(([id, family]) => ({ ...family, id, members: [...family.members], ...(family.aliases ? { aliases: { ...family.aliases } } : {}), ...(family.decorations ? { decorations: [...family.decorations] } : {}), ...(family.animation ? { animation: { ...family.animation } } : {}) })) };
142
183
  }