@dofy/native-emoji-motion 0.0.0-stage → 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/src/presets.js ADDED
@@ -0,0 +1,206 @@
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
+ }
21
+ // Motions transform the entire native glyph. Keyframes deliberately begin and end at rest.
22
+ export const motions = {
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)' } },
24
+ ride: { keyframes: [{ transform: 'translateY(0) rotate(0deg)' }, { transform: 'translateY(-15%) rotate(-6deg)', offset: .24 }, { transform: 'translateY(0) rotate(4deg)', offset: .48 }, { transform: 'translateY(-9%) rotate(-4deg)', offset: .68 }, { transform: 'translateY(0) rotate(0deg)' }], options: { duration: 950, easing: 'cubic-bezier(.22,1,.36,1)' }, origin: '50% 80%' },
25
+ wave: { keyframes: [{ transform: 'rotate(0deg)' }, { transform: 'rotate(18deg)' }, { transform: 'rotate(-12deg)' }, { transform: 'rotate(16deg)' }, { transform: 'rotate(-7deg)' }, { transform: 'rotate(0deg)' }], options: { duration: 850, easing: 'cubic-bezier(.22,1,.36,1)' }, origin: '70% 75%' },
26
+ bounce: { keyframes: [{ transform: 'translateY(0) scale(1)' }, { transform: 'translateY(-22%) scale(1.07,.94)', offset: .38 }, { transform: 'translateY(0) scale(.93,1.07)', offset: .72 }, { transform: 'translateY(0) scale(1)' }], options: { duration: 650, easing: 'cubic-bezier(.22,1,.36,1)' }, origin: '50% 85%' },
27
+ pulse: { keyframes: [{ transform: 'scale(1)' }, { transform: 'scale(1.25)', offset: .28 }, { transform: 'scale(.96)', offset: .5 }, { transform: 'scale(1.16)', offset: .68 }, { transform: 'scale(1)' }], options: { duration: 750, easing: 'cubic-bezier(.22,1,.36,1)' } },
28
+ wobble: { keyframes: [{ transform: 'rotate(0deg)' }, { transform: 'rotate(-13deg)' }, { transform: 'rotate(10deg)' }, { transform: 'rotate(-6deg)' }, { transform: 'rotate(3deg)' }, { transform: 'rotate(0deg)' }], options: { duration: 700, easing: 'cubic-bezier(.22,1,.36,1)' }, origin: '50% 80%' },
29
+ spin: { keyframes: [{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }], options: { duration: 800, easing: 'cubic-bezier(.22,1,.36,1)' } },
30
+ launch: { keyframes: [{ transform: 'translate(0,0) rotate(0deg)' }, { transform: 'translate(-5%,5%) rotate(-8deg)', offset: .22 }, { transform: 'translate(18%,-22%) rotate(8deg)', offset: .55 }, { transform: 'translate(0,0) rotate(0deg)' }], options: { duration: 900, easing: 'cubic-bezier(.22,1,.36,1)' } },
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%' },
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%' },
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)' } },
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%' }
50
+ };
51
+
52
+ // Groups describe semantic families; emoji entries may override the group's motion.
53
+ export const groups = {
54
+ gestures: { label: 'Gestures', motion: 'wave', description: 'Hands with a friendly wave.' },
55
+ faces: { label: 'Faces', motion: 'bounce', description: 'Expressions with a little bounce.' },
56
+ hearts: { label: 'Hearts', motion: 'pulse', description: 'A soft double heartbeat.' },
57
+ objects: { label: 'Objects', motion: 'wobble', description: 'Everyday things, gently brought to life.' },
58
+ nature: { label: 'Nature', motion: 'float', description: 'A light, drifting movement.' },
59
+ travel: { label: 'Travel', motion: 'launch', description: 'Things ready to take off.' },
60
+ symbols: { label: 'Symbols', motion: 'spin', description: 'A small turn for signs and shapes.' },
61
+ people: { label: 'People', motion: 'bounce', description: 'People and activities.' },
62
+ food: { label: 'Food', motion: 'bounce', description: 'Food and drink.' },
63
+ activities: { label: 'Activities', motion: 'bounce', description: 'Sports, arts and celebrations.' },
64
+ flags: { label: 'Flags', motion: 'float', description: 'A gentle flag motion.' },
65
+ other: { label: 'Other', motion: 'bounce', description: 'A gentle fallback.' }
66
+ };
67
+
68
+ // Explicit mappings avoid guessing at modifier placement in complex sequences.
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
+ };
81
+ // Variants are explicit: unrelated sequences are never silently normalized.
82
+ export const emoji = {
83
+ '👋': { group: 'gestures', name: 'Waving hand', skinTones: skinTones('👋') },
84
+ '👍': { group: 'gestures', name: 'Thumbs up', motion: 'bounce', skinTones: skinTones('👍') },
85
+ '👏': { group: 'gestures', name: 'Clapping hands', motion: 'shake', skinTones: skinTones('👏') },
86
+ '👎': { group: 'gestures', name: 'Thumbs down', motion: 'wobble', skinTones: skinTones('👎') },
87
+ '✋': { group: 'gestures', name: 'Raised hand', skinTones: skinTones('✋') },
88
+ '🖐️': { group: 'gestures', name: 'Hand with fingers splayed', skinTones: skinTones('🖐') },
89
+ '🤚': { group: 'gestures', name: 'Raised back of hand', skinTones: skinTones('🤚') },
90
+ '🤘': { group: 'gestures', name: 'Sign of the horns', motion: 'bounce', skinTones: skinTones('🤘') },
91
+ '🤙': { group: 'gestures', name: 'Call me hand', motion: 'wobble', skinTones: skinTones('🤙') },
92
+ '👌': { group: 'gestures', name: 'OK hand', motion: 'pulse', skinTones: skinTones('👌') },
93
+ '🤞': { group: 'gestures', name: 'Crossed fingers', motion: 'wobble', skinTones: skinTones('🤞') },
94
+ '🫶': { group: 'gestures', name: 'Heart hands', motion: 'pulse', skinTones: skinTones('🫶') },
95
+ '🙌': { group: 'gestures', name: 'Raising hands', motion: 'bounce', skinTones: skinTones('🙌') },
96
+ '🙏': { group: 'gestures', name: 'Folded hands', motion: 'bounce', skinTones: skinTones('🙏') },
97
+ '💪': { group: 'gestures', name: 'Flexed biceps', motion: 'pulse', skinTones: skinTones('💪') },
98
+ '👆': { group: 'gestures', name: 'Pointing up', motion: 'bounce', skinTones: skinTones('👆') },
99
+ '👇': { group: 'gestures', name: 'Pointing down', motion: 'bounce', skinTones: skinTones('👇') },
100
+ '👉': { group: 'gestures', name: 'Pointing right', motion: 'wobble', skinTones: skinTones('👉') },
101
+ '😊': { group: 'faces', name: 'Smiling face' }, '😂': { group: 'faces', name: 'Tears of joy', motion: 'shake' }, '🥳': { group: 'faces', name: 'Party face', motion: 'wobble' },
102
+ '😍': { group: 'faces', name: 'Smiling face with heart eyes', motion: 'pulse' }, '😉': { group: 'faces', name: 'Winking face', motion: 'wobble' }, '😎': { group: 'faces', name: 'Sunglasses face', motion: 'wobble' }, '🤔': { group: 'faces', name: 'Thinking face', motion: 'wobble' }, '😴': { group: 'faces', name: 'Sleeping face', motion: 'float' }, '😭': { group: 'faces', name: 'Loudly crying face', motion: 'shake' },
103
+ '❤️': { group: 'hearts', name: 'Red heart', variants: ['❤'] }, '💛': { group: 'hearts', name: 'Yellow heart' }, '💖': { group: 'hearts', name: 'Sparkling heart' },
104
+ '💚': { group: 'hearts', name: 'Green heart' }, '💙': { group: 'hearts', name: 'Blue heart' }, '💜': { group: 'hearts', name: 'Purple heart' }, '💔': { group: 'hearts', name: 'Broken heart', motion: 'shake' },
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: ['⏱'] },
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' },
107
+ '🌸': { group: 'nature', name: 'Blossom', decoration: 'petals' }, '🍃': { group: 'nature', name: 'Leaves' }, '☀️': { group: 'nature', name: 'Sun', motion: 'spin', variants: ['☀'] },
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' },
109
+ '🚀': { group: 'travel', name: 'Rocket' }, '✈️': { group: 'travel', name: 'Airplane', motion: 'glide', variants: ['✈'] }, '🚲': { group: 'travel', name: 'Bicycle', motion: 'ride' },
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' },
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' }
149
+ };
150
+
151
+ // Declarative SVG layers never alter the native glyph. Anchors are relative to
152
+ // the consumer's glyph container and may be overridden for a particular font.
153
+ const signalShape = { viewBox: '0 0 100 100', elements: [{ tag: 'circle', attrs: { cx: '50', cy: '50', r: '29', fill: 'none', stroke: '#7c8ee8', 'stroke-width': '2.5' } }] };
154
+ const notesShape = { viewBox: '0 0 100 100', elements: [
155
+ { tag: 'path', attrs: { d: 'M24 37c-7 3-7 8-3 10s9-1 9-5V23l12-3v15c-7 3-7 8-3 10s9-1 9-5V12l-24 6z', fill: '#b780c8' } },
156
+ { tag: 'path', attrs: { d: 'M75 67c-5 2-5 6-2 7s7-1 7-4V54l8-2v12c-5 2-5 6-2 7s7-1 7-4V46l-18 5z', fill: '#dc8f72' } }
157
+ ] };
158
+ const sparkleShape = { viewBox: '0 0 100 100', elements: [
159
+ { tag: 'path', attrs: { d: 'M18 9c1.5 9 3 10.5 12 12-9 1.5-10.5 3-12 12-1.5-9-3-10.5-12-12 9-1.5 10.5-3 12-12z', fill: '#e0a63e' } },
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' } }
161
+ ] };
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
+ ] };
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
+ }] },
184
+ signal: {
185
+ anchor: { x: '50%', y: '50%' },
186
+ layers: [0, 180, 360].map(delay => ({
187
+ svg: signalShape, style: centered,
188
+ keyframes: [{ opacity: .75, transform: 'translate(-50%, -50%) scale(.55)' }, { opacity: 0, transform: 'translate(-50%, -50%) scale(1.2)' }],
189
+ options: { duration: 700, delay, easing: 'cubic-bezier(.22,1,.36,1)', fill: 'backwards' }
190
+ }))
191
+ },
192
+ sound: {
193
+ anchor: { x: '50%', y: '50%' },
194
+ layers: [{ svg: notesShape, style: centered,
195
+ keyframes: [{ opacity: 0, transform: 'translate(-50%, -45%) scale(.75)' }, { opacity: .9, transform: 'translate(-50%, -55%) scale(1)', offset: .35 }, { opacity: 0, transform: 'translate(-50%, -70%) scale(1.1)' }],
196
+ options: { duration: 900, easing: 'cubic-bezier(.22,1,.36,1)' }
197
+ }]
198
+ },
199
+ sparkle: {
200
+ anchor: { x: '50%', y: '50%' },
201
+ layers: [{ svg: sparkleShape, style: centered,
202
+ keyframes: [{ opacity: 0, transform: 'translate(-50%, -50%) scale(.6) rotate(-15deg)' }, { opacity: 1, transform: 'translate(-50%, -50%) scale(1)', offset: .42 }, { opacity: 0, transform: 'translate(-50%, -50%) scale(1.2) rotate(10deg)' }],
203
+ options: { duration: 850, easing: 'cubic-bezier(.22,1,.36,1)' }
204
+ }]
205
+ }
206
+ };
@@ -0,0 +1,20 @@
1
+ 'use client';
2
+ import { createElement, useEffect, useRef } from 'react';
3
+
4
+ const defaultEvents = ['pointerenter', 'focus', 'click'];
5
+ /** A native emoji with optional motion and decorations. */
6
+ export function createEmojiMotionComponent(emojiMotion) {
7
+ return function EmojiMotion({ emoji, skinTone, registry = emojiMotion, decoration = true, events = defaultEvents, triggerRef, display = 'inline', label, className, style, ...attributes }) {
8
+ const wrapper = useRef(null);
9
+ const glyph = useRef(null);
10
+ const visual = useRef(null);
11
+ const result = registry.resolve(emoji, { skinTone });
12
+ useEffect(() => {
13
+ if (!wrapper.current || !glyph.current) return;
14
+ return registry.bind(glyph.current, emoji, { trigger: triggerRef?.current ?? wrapper.current, events, skinTone, ...(decoration ? { decoration: { container: visual.current } } : {}) });
15
+ }, [registry, emoji, skinTone, decoration, events, triggerRef]);
16
+ return createElement('span', { ...attributes, className, ref: wrapper, role: 'img', 'aria-label': label ?? result?.name ?? emoji, tabIndex: attributes.tabIndex ?? (triggerRef ? -1 : 0), style: { ...style, display: display === 'block' ? 'block' : 'inline-block' } },
17
+ createElement('span', { ref: visual, style: { display: 'inline-block', position: 'relative' } },
18
+ createElement('span', { ref: glyph, 'aria-hidden': 'true', style: { display: 'inline-block' } }, result?.glyph ?? emoji)));
19
+ }
20
+ }
@@ -0,0 +1,4 @@
1
+ 'use client';
2
+ import { emojiMotion } from './lite.js';
3
+ import { createEmojiMotionComponent } from './react-core.js';
4
+ export const EmojiMotion = createEmojiMotionComponent(emojiMotion);
package/src/react.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { CSSProperties, HTMLAttributes, ReactElement, RefObject } from 'react';
2
+ import type { EmojiMotion as Registry, SkinTone } from './index.js';
3
+ export interface EmojiMotionProps extends Omit<HTMLAttributes<HTMLSpanElement>, 'children'> {
4
+ emoji: string;
5
+ skinTone?: SkinTone;
6
+ registry?: Registry;
7
+ decoration?: boolean;
8
+ display?: 'inline' | 'block';
9
+ events?: string[];
10
+ label?: string;
11
+ triggerRef?: RefObject<HTMLElement | null>;
12
+ style?: CSSProperties;
13
+ }
14
+ export declare function EmojiMotion(props: EmojiMotionProps): ReactElement;
package/src/react.js ADDED
@@ -0,0 +1,4 @@
1
+ 'use client';
2
+ import { emojiMotion } from './index.js';
3
+ import { createEmojiMotionComponent } from './react-core.js';
4
+ export const EmojiMotion = createEmojiMotionComponent(emojiMotion);
@@ -0,0 +1,183 @@
1
+ import { motions as defaultMotions, groups as defaultGroups, emoji as defaultEmoji, decorations as defaultDecorations, families as defaultFamilies } from './presets.js';
2
+
3
+ const toneNames = ['default', 'light', 'medium-light', 'medium', 'medium-dark', 'dark'];
4
+ function validateTone(tone) { if (!toneNames.includes(tone)) throw new Error(`Unknown skin tone "${tone}"`); }
5
+ function defaultClassification(info) {
6
+ const group = info.unicodeGroup; const sub = info.unicodeSubgroup;
7
+ if (group === 'Smileys & Emotion') return { group: sub === 'heart' ? 'hearts' : 'faces' };
8
+ if (group === 'People & Body') return { group: sub.startsWith('hand') ? 'gestures' : 'people', ...(sub.startsWith('hand') && sub !== 'hand-fingers-open' ? { motion: 'wobble' } : {}) };
9
+ if (group === 'Animals & Nature') return { group: 'nature', ...(sub.startsWith('animal-') ? { motion: 'bounce' } : {}) };
10
+ if (group === 'Food & Drink') return { group: 'food' };
11
+ if (group === 'Travel & Places') return { group: 'travel', motion: sub === 'transport-ground' ? 'ride' : sub === 'transport-air' ? 'glide' : sub === 'transport-water' ? 'wobble' : 'float' };
12
+ if (group === 'Activities') return { group: 'activities' };
13
+ if (group === 'Objects') return { group: 'objects' };
14
+ if (group === 'Symbols') return { group: 'symbols', motion: 'pulse' };
15
+ if (group === 'Flags') return { group: 'flags' };
16
+ return { group: 'other' };
17
+ }
18
+
19
+ /** Build a registry with an optional classifier. User definitions always win. */
20
+ export function createRegistry(config = {}, classifier) {
21
+ const useDefaults = config.defaults !== false;
22
+ const motions = { ...(useDefaults ? defaultMotions : {}), ...config.motions };
23
+ const decorations = { ...(useDefaults ? defaultDecorations : {}), ...config.decorations };
24
+ const groups = { ...(useDefaults ? defaultGroups : {}), ...config.groups };
25
+ const builtin = useDefaults ? defaultEmoji : {};
26
+ const user = config.emoji ?? {};
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))) : {};
30
+ if (config.autoDetect === true && !classifier) throw new Error('Automatic classification is not included in the lite entry');
31
+ const autoDetect = (config.autoDetect ?? useDefaults) && Boolean(classifier);
32
+ const rules = config.classifications ?? {};
33
+ let defaultSkinTone = config.defaultSkinTone ?? 'default'; validateTone(defaultSkinTone);
34
+ function makeAliases(definitions) {
35
+ const result = new Map();
36
+ for (const [glyph, entry] of Object.entries(definitions)) {
37
+ if (!groups[entry.group]) throw new Error(`Unknown group "${entry.group}" for ${glyph}`);
38
+ const name = entry.motion ?? groups[entry.group].motion;
39
+ if (!motions[name]) throw new Error(`Unknown motion "${name}" for ${glyph}`);
40
+ if (entry.decoration && !decorations[entry.decoration]) throw new Error(`Unknown decoration "${entry.decoration}" for ${glyph}`);
41
+ for (const tone of Object.keys(entry.skinTones ?? {})) { validateTone(tone); if (tone === 'default') throw new Error('Use the canonical glyph for the default skin tone'); }
42
+ for (const alias of [...(entry.variants ?? []), ...Object.values(entry.skinTones ?? {})]) {
43
+ if (Object.hasOwn(definitions, alias) || result.has(alias)) throw new Error(`Duplicate emoji variant: ${alias}`);
44
+ result.set(alias, glyph);
45
+ }
46
+ }
47
+ return result;
48
+ }
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);
76
+ for (const [id, group] of Object.entries(groups)) if (!motions[group.motion]) throw new Error(`Unknown motion "${group.motion}" for group ${id}`);
77
+ for (const rule of [...Object.values(rules.groups ?? {}), ...Object.values(rules.subgroups ?? {}), ...(rules.fallback ? [rules.fallback] : [])]) {
78
+ if (!groups[rule.group]) throw new Error(`Unknown classification group "${rule.group}"`);
79
+ if (rule.motion && !motions[rule.motion]) throw new Error(`Unknown classification motion "${rule.motion}"`);
80
+ }
81
+ const active = new WeakMap();
82
+ function resolve(glyph, options = {}) {
83
+ if (typeof glyph !== 'string') return undefined;
84
+ if (options.skinTone !== undefined) validateTone(options.skinTone);
85
+ const info = autoDetect ? classifier.lookup(glyph) : undefined;
86
+ const desiredTone = options.skinTone ?? info?.tone ?? defaultSkinTone;
87
+ const requestedVariant = info && !info.multiTone && desiredTone !== 'default' ? info.skinTones[desiredTone] : undefined;
88
+ const candidates = [requestedVariant, glyph, userAliases.get(requestedVariant), userAliases.get(glyph), info?.canonical, builtinAliases.get(glyph)].filter(Boolean);
89
+ let canonical = candidates.find(key => Object.hasOwn(user, key));
90
+ let source = canonical ? 'user' : undefined;
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;
95
+ // A custom emoji wins first, followed by custom classification rules.
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;
98
+ let entry;
99
+ let autoRule;
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];
105
+ else if (info) {
106
+ canonical = info.canonical; source = userRule ? 'user-classification' : 'unicode';
107
+ autoRule = userRule ?? defaultClassification(info);
108
+ // A user-defined group overrides the default automatic motion too.
109
+ if (!userRule && config.groups?.[autoRule.group]) autoRule = { group: autoRule.group };
110
+ entry = { group: autoRule.group, name: info.name, motion: autoRule.motion };
111
+ } else if (autoDetect && classifier.looksLikeEmoji(glyph)) {
112
+ canonical = glyph; source = 'fallback'; autoRule = rules.fallback ?? { group: 'other' };
113
+ entry = { group: autoRule.group, name: 'Emoji', motion: autoRule.motion };
114
+ } else return undefined;
115
+ const group = groups[entry.group];
116
+ if (!group) throw new Error(`Unknown classification group "${entry.group}"`);
117
+ // An explicitly supplied group default outranks handcrafted preset motions.
118
+ // A user-defined emoji or classification remains more specific.
119
+ const groupOverride = (source === 'preset' || (source === 'family' && !userFamily)) && Object.hasOwn(config.groups ?? {}, entry.group);
120
+ const motionId = groupOverride ? group.motion : entry.motion ?? group.motion;
121
+ if (!motions[motionId]) throw new Error(`Unknown classification motion "${motionId}"`);
122
+ const mappedTones = entry.skinTones ?? info?.skinTones;
123
+ const explicitTone = entry.skinTones ? Object.entries(entry.skinTones).find(([, variant]) => variant === glyph)?.[0] : info?.tone;
124
+ const skinTone = options.skinTone ?? explicitTone ?? defaultSkinTone;
125
+ let renderedGlyph = glyph;
126
+ // Mixed-tone sequences are retained: no single requested tone can safely rewrite them.
127
+ if (mappedTones && !info?.multiTone) {
128
+ if (skinTone !== 'default') renderedGlyph = mappedTones[skinTone] ?? glyph;
129
+ else if (explicitTone) renderedGlyph = canonical;
130
+ }
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 } : {}) };
134
+ }
135
+ function play(element, glyph, options = {}) {
136
+ const result = resolve(glyph, { skinTone: options.skinTone });
137
+ if (!result || typeof element?.animate !== 'function') return null;
138
+ if (!options.ignoreReducedMotion && typeof globalThis.matchMedia === 'function' && globalThis.matchMedia('(prefers-reduced-motion: reduce)').matches) return null;
139
+ active.get(element)?.dispose();
140
+ const { motion } = result;
141
+ // transformOrigin belongs in keyframes so it never mutates the host element's styles.
142
+ const keyframes = motion.keyframes.map(frame => motion.origin ? { ...frame, transformOrigin: motion.origin } : { ...frame });
143
+ const animation = element.animate(keyframes, { ...motion.options, ...options.animation });
144
+ const decorationRoots = [];
145
+ const decorationAnimations = [];
146
+ const decorationOptions = options.decoration;
147
+ if (decorationOptions) for (const preset of result.decorationPresets) {
148
+ const { container, anchor = preset.anchor, flipX = false, rotate = 0 } = decorationOptions;
149
+ const decorationRoot = container.ownerDocument.createElement('span');
150
+ decorationRoots.push(decorationRoot);
151
+ decorationRoot.setAttribute('aria-hidden', 'true');
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)` });
153
+ container.appendChild(decorationRoot);
154
+ const start = decorationAnimations.length;
155
+ for (const layer of preset.layers) {
156
+ const node = layer.svg ? container.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'svg') : container.ownerDocument.createElement('span');
157
+ if (layer.svg) {
158
+ node.setAttribute('viewBox', layer.svg.viewBox);
159
+ for (const shape of layer.svg.elements) {
160
+ const child = container.ownerDocument.createElementNS('http://www.w3.org/2000/svg', shape.tag);
161
+ for (const [key, value] of Object.entries(shape.attrs)) child.setAttribute(key, value);
162
+ node.appendChild(child);
163
+ }
164
+ }
165
+ Object.assign(node.style, { position: 'absolute', left: '0', top: '0', opacity: '0', ...layer.style });
166
+ decorationRoot.appendChild(node);
167
+ decorationAnimations.push(node.animate(layer.keyframes, layer.options));
168
+ }
169
+ const layerAnimations = decorationAnimations.slice(start);
170
+ Promise.allSettled(layerAnimations.map(item => item.finished)).then(() => decorationRoot.remove());
171
+ }
172
+ active.set(element, { dispose() { animation.cancel(); decorationAnimations.forEach(item => item.cancel()); decorationRoots.forEach(root => root.remove()); } });
173
+ return animation;
174
+ }
175
+ function bind(element, glyph, options = {}) {
176
+ const trigger = options.trigger ?? element;
177
+ const events = options.events ?? ['pointerenter', 'focus'];
178
+ const handler = () => play(element, glyph, options);
179
+ for (const event of events) trigger.addEventListener(event, handler);
180
+ return () => { for (const event of events) trigger.removeEventListener(event, handler); active.get(element)?.dispose(); active.delete(element); };
181
+ }
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 } } : {}) })) };
183
+ }
@@ -0,0 +1,15 @@
1
+
2
+ /** Svelte action. Apply it to a span dedicated to the emoji. */
3
+ export function createAnimateEmoji(emojiMotion) {
4
+ return function animateEmoji(node, initial) {
5
+ let dispose = () => {};
6
+ function update(options) {
7
+ dispose();
8
+ const { emoji, skinTone, registry = emojiMotion, trigger = node, decoration = false, events = ['pointerenter', 'focus', 'click'] } = options;
9
+ node.textContent = registry.resolve(emoji, { skinTone })?.glyph ?? emoji;
10
+ dispose = registry.bind(node, emoji, { trigger, events, skinTone, ...(decoration && node.parentElement ? { decoration: { container: node.parentElement } } : {}) });
11
+ }
12
+ update(initial);
13
+ return { update, destroy() { dispose(); } };
14
+ }
15
+ }
@@ -0,0 +1,3 @@
1
+ import { emojiMotion } from './lite.js';
2
+ import { createAnimateEmoji } from './svelte-core.js';
3
+ export const animateEmoji = createAnimateEmoji(emojiMotion);
@@ -0,0 +1,3 @@
1
+ import type { EmojiMotion as Registry, SkinTone } from './index.js';
2
+ export interface AnimateEmojiOptions { emoji: string; skinTone?: SkinTone; registry?: Registry; trigger?: EventTarget; decoration?: boolean; events?: string[] }
3
+ export declare function animateEmoji(node: HTMLElement, options: AnimateEmojiOptions): { update(options: AnimateEmojiOptions): void; destroy(): void };
package/src/svelte.js ADDED
@@ -0,0 +1,3 @@
1
+ import { emojiMotion } from './index.js';
2
+ import { createAnimateEmoji } from './svelte-core.js';
3
+ export const animateEmoji = createAnimateEmoji(emojiMotion);