@dofy/native-emoji-motion 0.6.4 → 0.7.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,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0 — 2026-11-14
4
+
5
+ - **Behavior change:** emoji that use the bounce, hop or spring motions, including most animals and faces, now move with the new rhythm. The game die, alarm clock, cartwheeling person and turtle use new motions, and the hammer leaves the hand tools family.
6
+ - Give springs, bounces, hops and pop a gravity-aware rhythm with anticipation, per-segment easing and squash on landing.
7
+ - Add toss, somersault, alarmRing, peek and slam motions for dice, cartwheels, alarm clocks and telephones, peeking faces and the hammer.
8
+ - Burst sparkles one after another, and add a scan effect for screens and documents.
9
+ - Let decoration layers paint behind the glyph, and read built-in effect colors from CSS custom properties.
10
+ - `bind()` ignores touch hover, mouse focus and repeats within its new `cooldown` (260 ms by default).
11
+
3
12
  ## 0.6.4 — 2026-11-14
4
13
 
5
14
  - Add ripple, splash and wake effects for water.
package/README.md CHANGED
@@ -24,13 +24,13 @@ Bring 👋, ❤️, 📡 and more to life in React, Vue, Svelte or plain HTML. E
24
24
 
25
25
  ## 📊 Collection at a glance
26
26
 
27
- | In version 0.6.4 | Count | What it covers |
27
+ | In version 0.7.0 | Count | What it covers |
28
28
  | --- | ---: | --- |
29
- | Handcrafted presets | 177 | Individually configured base emoji |
30
- | Reviewed motion families | 31 | 212 additional base emoji sharing reviewed motions |
29
+ | Handcrafted presets | 182 | Individually configured base emoji |
30
+ | Reviewed motion families | 34 | 223 additional base emoji sharing reviewed motions |
31
31
  | Categories | 10 | Groups used to organize the collection |
32
- | Reusable motions | 53 | Motions shared by presets and families |
33
- | Emoji with optional SVG effects | 96 | Base emoji with decorative effects |
32
+ | Reusable motions | 58 | Motions shared by presets and families |
33
+ | Emoji with optional SVG effects | 106 | Base emoji with decorative effects |
34
34
  | Recognized Unicode sequences | 5,235 | Unicode Emoji 18.0 sequences, including skin tones and other variants |
35
35
 
36
36
  Skin tones and other variants count as additional Unicode sequences. Similar emoji may share a motion. Recognized emoji without a preset or reviewed family receive a category-based fallback.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dofy/native-emoji-motion",
3
- "version": "0.6.4",
3
+ "version": "0.7.0",
4
4
  "description": "Expressive, extensible animations for native emoji with optional SVG effects",
5
5
  "type": "module",
6
6
  "exports": {
@@ -14,7 +14,7 @@
14
14
 
15
15
  <span bind:this={wrapper} data-emoji-motion="component" role="img" aria-label={label ?? resolved?.name ?? emoji}
16
16
  tabindex={trigger ? -1 : 0}>
17
- <span style="display: inline-block; position: relative">
17
+ <span style="display: inline-block; position: relative; isolation: isolate">
18
18
  <span aria-hidden="true" style="display: inline-block"
19
19
  use:animateEmoji={{ emoji, skinTone, registry, trigger: trigger ?? wrapper, decoration, events }}>{resolved?.glyph ?? emoji}</span>
20
20
  </span>
@@ -14,7 +14,7 @@
14
14
 
15
15
  <span bind:this={wrapper} data-emoji-motion="component" role="img" aria-label={label ?? resolved?.name ?? emoji}
16
16
  tabindex={trigger ? -1 : 0}>
17
- <span style="display: inline-block; position: relative">
17
+ <span style="display: inline-block; position: relative; isolation: isolate">
18
18
  <span aria-hidden="true" style="display: inline-block"
19
19
  use:animateEmoji={{ emoji, skinTone, registry, trigger: trigger ?? wrapper, decoration, events }}>{resolved?.glyph ?? emoji}</span>
20
20
  </span>
@@ -19,7 +19,7 @@ export function createEmojiMotionElement(emojiMotion) {
19
19
  this.setAttribute('aria-label', this.getAttribute('label') ?? result?.name ?? emoji);
20
20
  if (!this.hasAttribute('tabindex')) this.tabIndex = 0;
21
21
  const visual = this.ownerDocument.createElement('span');
22
- Object.assign(visual.style, { display: 'inline-block', position: 'relative' });
22
+ Object.assign(visual.style, { display: 'inline-block', position: 'relative', isolation: 'isolate' });
23
23
  const glyph = this.ownerDocument.createElement('span');
24
24
  Object.assign(glyph.style, { display: 'inline-block' });
25
25
  glyph.setAttribute('aria-hidden', 'true'); glyph.textContent = result?.glyph ?? emoji;
package/src/enhance.js CHANGED
@@ -36,7 +36,7 @@ export function enhance(registry, options = {}) {
36
36
  if (index > cursor) fragment.append(node.ownerDocument.createTextNode(text.slice(cursor, index)));
37
37
  const wrapper = node.ownerDocument.createElement('span');
38
38
  wrapper.setAttribute('data-emoji-motion', 'enhanced');
39
- Object.assign(wrapper.style, { display: 'inline-block', position: 'relative' });
39
+ Object.assign(wrapper.style, { display: 'inline-block', position: 'relative', isolation: 'isolate' });
40
40
  const inner = node.ownerDocument.createElement('span');
41
41
  inner.style.display = 'inline-block';
42
42
  inner.textContent = glyph;
package/src/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export interface Motion { keyframes: Keyframe[]; options?: KeyframeAnimationOpti
2
2
  export interface Group { label: string; description?: string; motion: string }
3
3
  export interface Anchor { x: string; y: string }
4
4
  export interface SvgShape { tag: 'path' | 'circle' | 'ellipse' | 'rect' | 'line' | 'polyline' | 'polygon'; attrs: Record<string, string> }
5
- export interface DecorationLayer { style: Partial<CSSStyleDeclaration>; keyframes: Keyframe[]; options?: KeyframeAnimationOptions; svg?: { viewBox: string; elements: SvgShape[] } }
5
+ export interface DecorationLayer { /** Paint behind the glyph; the container must create a stacking context, for example with isolation: isolate. */ behind?: boolean; style: Partial<CSSStyleDeclaration>; keyframes: Keyframe[]; options?: KeyframeAnimationOptions; svg?: { viewBox: string; elements: SvgShape[] } }
6
6
  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>> }
@@ -10,7 +10,7 @@ export interface ClassificationRule { group: string; motion?: string }
10
10
  export interface MotionFamily { label: string; group: string; motion: string; members: string[]; aliases?: Record<string, string>; decoration?: string; decorations?: string[]; animation?: KeyframeAnimationOptions }
11
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> }
12
12
  export interface PlayOptions { ignoreReducedMotion?: boolean; skinTone?: SkinTone; animation?: KeyframeAnimationOptions; decoration?: { container: HTMLElement; anchor?: Anchor; flipX?: boolean; rotate?: number } }
13
- export interface BindOptions extends PlayOptions { trigger?: EventTarget; events?: string[] }
13
+ export interface BindOptions extends PlayOptions { trigger?: EventTarget; events?: string[]; /** Milliseconds during which repeated triggers are ignored. Defaults to 260. */ cooldown?: number }
14
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
15
  export interface EnhanceOptions { root?: string | Element | Document | DocumentFragment; exclude?: string; enabled?: boolean; observe?: boolean; decoration?: boolean; events?: string[] }
16
16
  export interface EmojiEnhancer { enable(): void; disable(): void; cleanup(): void }
package/src/presets.js CHANGED
@@ -3,15 +3,27 @@ function boundedNumber(name, value, min, max) {
3
3
  if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max) throw new RangeError(`${name} must be between ${min} and ${max}`);
4
4
  return value;
5
5
  }
6
+ // Gravity easings: rising slows towards the top, falling speeds up into the landing.
7
+ const rise = 'cubic-bezier(.2,.7,.35,1)';
8
+ const fall = 'cubic-bezier(.6,0,.85,.45)';
9
+ const crouch = 'cubic-bezier(.3,0,.6,1)';
10
+ const settle = 'ease-out';
6
11
  /** Build a direction-neutral bounce; height is a percentage of glyph height. */
7
12
  export function createSpringMotion({ height = 24, rebounds = 1, duration = 900 } = {}) {
8
13
  boundedNumber('height', height, 0, 60); boundedNumber('duration', duration, 1, 10000);
9
14
  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 });
15
+ // Crouch before take-off, stretch while rising, squash on landing.
16
+ const frames = [
17
+ { transform: 'translateY(0) scale(1)', offset: 0, easing: crouch },
18
+ { transform: 'translateY(0) scale(1.08,.92)', offset: .12, easing: rise },
19
+ { transform: `translateY(-${height}%) scale(.96,1.05)`, offset: .4, easing: fall },
20
+ { transform: 'translateY(0) scale(1.12,.88)', offset: .6, easing: rebounds ? rise : settle }
21
+ ];
22
+ if (rebounds >= 1) frames.push({ transform: `translateY(-${height * .28}%) scale(.98,1.02)`, offset: .74, easing: fall }, { transform: 'translateY(0) scale(1.04,.96)', offset: .85, easing: rebounds === 2 ? rise : settle });
23
+ if (rebounds === 2) frames.push({ transform: `translateY(-${height * .1}%) scale(1)`, offset: .92, easing: fall }, { transform: 'translateY(0) scale(1.01,.99)', offset: .97, easing: settle });
24
+ if (!rebounds) frames.push({ transform: 'translateY(0) scale(.98,1.02)', offset: .8, easing: settle });
13
25
  frames.push({ transform: 'translateY(0) scale(1)', offset: 1 });
14
- return { keyframes: frames, options: { duration, easing: curve }, origin: '50% 90%' };
26
+ return { keyframes: frames, options: { duration, easing: 'linear' }, origin: '50% 90%' };
15
27
  }
16
28
  /** Build a balanced sway; no assumption about the native glyph's direction. */
17
29
  export function createSwayMotion({ angle = 8, duration = 1200, origin = '50% 90%' } = {}) {
@@ -23,7 +35,7 @@ export const motions = {
23
35
  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
36
  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
37
  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%' },
38
+ bounce: { ...createSpringMotion({ height: 22, rebounds: 0, duration: 700 }), origin: '50% 85%' },
27
39
  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
40
  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
41
  spin: { keyframes: [{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }], options: { duration: 800, easing: 'cubic-bezier(.22,1,.36,1)' } },
@@ -32,14 +44,14 @@ export const motions = {
32
44
  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
45
  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
46
  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%' },
47
+ hop: createSpringMotion({ height: 28, rebounds: 1, duration: 850 }),
36
48
  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
49
  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
50
  flip: { keyframes: [{ transform: 'rotate(0deg)' }, { transform: 'rotate(185deg)', offset: .5 }, { transform: 'rotate(360deg)' }], options: { duration: 1000, easing: 'cubic-bezier(.22,1,.36,1)' } },
39
51
  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
52
  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
53
  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)' } },
54
+ pop: { keyframes: [{ transform: 'scale(1)', easing: crouch }, { transform: 'scale(.84)', offset: .18, easing: rise }, { transform: 'scale(1.2)', offset: .45, easing: 'ease-in-out' }, { transform: 'scale(.95)', offset: .68, easing: settle }, { transform: 'scale(1.04)', offset: .84, easing: settle }, { transform: 'scale(1)' }], options: { duration: 750, easing: 'linear' } },
43
55
  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
56
  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
57
  spring: createSpringMotion(),
@@ -62,7 +74,7 @@ export const motions = {
62
74
  orbitFloat: { keyframes: [{ transform: 'translateY(0) scale(1)' }, { transform: 'translateY(-9%) scale(1.04)', offset: .45 }, { transform: 'translateY(0) scale(1)' }], options: { duration: 1200, easing: curve } },
63
75
  writingTilt: { keyframes: [{ transform: 'rotate(0deg)' }, { transform: 'rotate(-5deg)', offset: .3 }, { transform: 'rotate(5deg)', offset: .65 }, { transform: 'rotate(0deg)' }], options: { duration: 1000, easing: curve }, origin: '50% 65%' },
64
76
  gardenBounce: { keyframes: [{ transform: 'translateY(0) rotate(0deg)' }, { transform: 'translateY(-6%) rotate(-3deg)', offset: .32 }, { transform: 'translateY(-3%) rotate(3deg)', offset: .65 }, { transform: 'translateY(0) rotate(0deg)' }], options: { duration: 1050, easing: curve }, origin: '50% 85%' },
65
- animalHop: { keyframes: [{ transform: 'translateY(0) scale(1)' }, { transform: 'translateY(-11%) scale(1.02,.98)', offset: .35 }, { transform: 'translateY(0) scale(1.04,.96)', offset: .68 }, { transform: 'translateY(0) scale(1)' }], options: { duration: 950, easing: curve }, origin: '50% 90%' },
77
+ animalHop: createSpringMotion({ height: 11, rebounds: 0, duration: 900 }),
66
78
  fabricSway: createSwayMotion({ angle: 6, duration: 1250, origin: '50% 15%' }),
67
79
  toolWiggle: createSwayMotion({ angle: 7, duration: 900, origin: '50% 70%' }),
68
80
  bookPeek: { keyframes: [{ transform: 'perspective(500px) rotateY(0deg) translateY(0)' }, { transform: 'perspective(500px) rotateY(-20deg) translateY(-3%)', offset: .32 }, { transform: 'perspective(500px) rotateY(20deg) translateY(-3%)', offset: .68 }, { transform: 'perspective(500px) rotateY(0deg) translateY(0)' }], options: { duration: 1050, easing: curve } },
@@ -72,6 +84,64 @@ export const motions = {
72
84
  clockTick: { keyframes: [{ transform: 'rotate(0deg)' }, { transform: 'rotate(-3deg)', offset: .25 }, { transform: 'rotate(3deg)', offset: .5 }, { transform: 'rotate(-2deg)', offset: .75 }, { transform: 'rotate(0deg)' }], options: { duration: 1200, easing: curve }, origin: '50% 50%' },
73
85
  gentleFall: { keyframes: [{ transform: 'translateY(0) scale(1)' }, { transform: 'translateY(7%) scale(.98,1.02)', offset: .45 }, { transform: 'translateY(0) scale(1)' }], options: { duration: 1100, easing: curve } },
74
86
  softSettle: { keyframes: [{ transform: 'translateY(0) scale(1)' }, { transform: 'translateY(-5%) scale(1)', offset: .35 }, { transform: 'translateY(0) scale(1.03,.97)', offset: .7 }, { transform: 'translateY(0) scale(1)' }], options: { duration: 950, easing: curve }, origin: '50% 90%' },
87
+ // Rhythm motions: a beat of anticipation, the move, a held pause, then a reversal into rest.
88
+ // Tumble up, hang near the top, drop flat, overshoot and settle back.
89
+ toss: { keyframes: [
90
+ { transform: 'translateY(0) rotate(0deg) scale(1)', easing: crouch },
91
+ { transform: 'translateY(4%) rotate(-10deg) scale(1.12,.88)', offset: .12, easing: 'cubic-bezier(.15,.75,.35,1)' },
92
+ { transform: 'translateY(-56%) rotate(190deg) scale(1.06)', offset: .44, easing: 'cubic-bezier(.45,.05,.55,.95)' },
93
+ { transform: 'translateY(-50%) rotate(255deg) scale(1.06)', offset: .56, easing: fall },
94
+ { transform: 'translateY(0) rotate(360deg) scale(1.14,.86)', offset: .76, easing: settle },
95
+ { transform: 'translateY(-4%) rotate(366deg) scale(.98,1.02)', offset: .86, easing: settle },
96
+ { transform: 'translateY(0) rotate(357deg) scale(1)', offset: .93, easing: settle },
97
+ { transform: 'translateY(0) rotate(360deg) scale(1)' }
98
+ ], options: { duration: 1000, easing: 'linear' }, origin: '50% 55%' },
99
+ // A full roll whose slowest part is the top, so the turn seems to hang before landing.
100
+ somersault: { keyframes: [
101
+ { transform: 'translateY(0) rotate(0deg) scale(1)', easing: crouch },
102
+ { transform: 'translateY(5%) rotate(0deg) scale(1.1,.9)', offset: .14, easing: rise },
103
+ { transform: 'translateY(-40%) rotate(-150deg) scale(1)', offset: .36, easing: 'linear' },
104
+ { transform: 'translateY(-44%) rotate(-215deg) scale(1)', offset: .5, easing: fall },
105
+ { transform: 'translateY(0) rotate(-360deg) scale(1.12,.88)', offset: .72, easing: settle },
106
+ { transform: 'translateY(-3%) rotate(-360deg) scale(.98,1.02)', offset: .86, easing: settle },
107
+ { transform: 'translateY(0) rotate(-360deg) scale(1)' }
108
+ ], options: { duration: 1100, easing: 'linear' }, origin: '50% 60%' },
109
+ // Two bursts of quick shaking separated by a held breath, like an alarm clock.
110
+ alarmRing: { keyframes: [
111
+ { transform: 'translateY(0) rotate(0deg)', easing: 'ease-out' },
112
+ { transform: 'translateY(-8%) rotate(-16deg)', offset: .06 },
113
+ { transform: 'translateY(-8%) rotate(16deg)', offset: .12 },
114
+ { transform: 'translateY(-8%) rotate(-15deg)', offset: .18 },
115
+ { transform: 'translateY(-8%) rotate(15deg)', offset: .24 },
116
+ { transform: 'translateY(-8%) rotate(-9deg)', offset: .3, easing: 'ease-out' },
117
+ { transform: 'translateY(-7%) rotate(0deg)', offset: .36 },
118
+ { transform: 'translateY(-6%) rotate(0deg)', offset: .5 },
119
+ { transform: 'translateY(-6%) rotate(-12deg)', offset: .56 },
120
+ { transform: 'translateY(-6%) rotate(12deg)', offset: .62 },
121
+ { transform: 'translateY(-6%) rotate(-8deg)', offset: .68 },
122
+ { transform: 'translateY(-5%) rotate(6deg)', offset: .74, easing: 'ease-out' },
123
+ { transform: 'translateY(0) rotate(-2deg)', offset: .86, easing: settle },
124
+ { transform: 'translateY(0) rotate(0deg)' }
125
+ ], options: { duration: 1100, easing: 'linear' }, origin: '50% 60%' },
126
+ // Duck out of sight, wait, then pop back with a look one way and the other.
127
+ peek: { keyframes: [
128
+ { transform: 'translateY(0) scale(1) rotate(0deg)', opacity: 1, easing: 'cubic-bezier(.5,0,.75,0)' },
129
+ { transform: 'translateY(8%) scale(.6) rotate(0deg)', opacity: .2, offset: .16, easing: 'linear' },
130
+ { transform: 'translateY(8%) scale(.6) rotate(0deg)', opacity: .2, offset: .4, easing: rise },
131
+ { transform: 'translateY(-6%) scale(1.08) rotate(10deg)', opacity: 1, offset: .58, easing: 'ease-in-out' },
132
+ { transform: 'translateY(-4%) scale(1.04) rotate(-7deg)', opacity: 1, offset: .74, easing: settle },
133
+ { transform: 'translateY(0) scale(1) rotate(2deg)', opacity: 1, offset: .88, easing: settle },
134
+ { transform: 'translateY(0) scale(1) rotate(0deg)', opacity: 1 }
135
+ ], options: { duration: 1100, easing: 'linear' } },
136
+ // Lift and hold at the top, then come down hard and squash on impact.
137
+ slam: { keyframes: [
138
+ { transform: 'translateY(0) rotate(0deg) scale(1)', easing: rise },
139
+ { transform: 'translateY(-30%) rotate(-10deg) scale(1.06)', offset: .34, easing: 'linear' },
140
+ { transform: 'translateY(-32%) rotate(-12deg) scale(1.06)', offset: .44, easing: fall },
141
+ { transform: 'translateY(3%) rotate(0deg) scale(1.18,.82)', offset: .56, easing: settle },
142
+ { transform: 'translateY(-4%) rotate(2deg) scale(.96,1.04)', offset: .72, easing: settle },
143
+ { transform: 'translateY(0) rotate(0deg) scale(1)' }
144
+ ], options: { duration: 900, easing: 'linear' }, origin: '50% 90%' },
75
145
  steps: { keyframes: [{ transform: 'translateY(0)' }, { transform: 'translateY(-7%)', offset: .25 }, { transform: 'translateY(0)', offset: .5 }, { transform: 'translateY(-5%)', offset: .75 }, { transform: 'translateY(0)' }], options: { duration: 1000, easing: curve }, origin: '50% 90%' }
76
146
  };
77
147
 
@@ -117,7 +187,7 @@ export const families = {
117
187
  vegetables: { label: 'Vegetables', group: 'food', motion: 'gardenBounce', members: ['🥕', '🥦', '🥬', '🥒', '🌽', '🫑', '🍆', '🧅', '🧄', '🥔'] },
118
188
  landAnimals: { label: 'Land animals', group: 'nature', motion: 'animalHop', members: ['🐕', '🐈', '🐖', '🐑', '🐐', '🐄', '🐎', '🦌', '🐘', '🦒'] },
119
189
  clothing: { label: 'Clothing', group: 'objects', motion: 'fabricSway', members: ['👕', '👖', '🧥', '👗', '🩳', '👚', '👔'] },
120
- handTools: { label: 'Hand tools', group: 'objects', motion: 'toolWiggle', members: ['🔨', '🪓', '🪚', '🪛', '🔧', '🪠', '🔩'] },
190
+ handTools: { label: 'Hand tools', group: 'objects', motion: 'toolWiggle', members: ['🪓', '🪚', '🪛', '🔧', '🪠', '🔩'] },
121
191
  books: { label: 'Books', group: 'objects', motion: 'bookPeek', members: ['📕', '📗', '📘', '📙', '📔', '📓', '📒'] },
122
192
  handGestures: { label: 'Hand gestures', group: 'gestures', motion: 'handEmphasis', members: ['🤏', '🫳', '🫴', '🫲', '🫱', '🫷', '🫸', '🤌', '🫵'], aliases: familyToneAliases(['🤏', '🫳', '🫴', '🫲', '🫱', '🫷', '🫸', '🤌', '🫵']) },
123
193
  vessels: { label: 'Boats', group: 'travel', motion: 'vesselRock', members: ['🚤', '🛥️', '🛳️', '⛴️', '🛶'], aliases: { '🛥': '🛥️', '🛳': '🛳️', '⛴': '⛴️' }, decoration: 'wake' },
@@ -134,6 +204,9 @@ export const families = {
134
204
  poses: { label: 'People in poses', group: 'people', motion: 'pose', members: ['🧎', '🧍', '🙇'], aliases: familyToneAliases(['🧎', '🧍', '🙇']) },
135
205
  professionals: { label: 'Professionals', group: 'people', motion: 'pose', members: roleMembers, aliases: roleAliases },
136
206
  walkers: { label: 'Walking and running', group: 'people', motion: 'steps', members: walkerMembers, aliases: walkerAliases },
207
+ ringers: { label: 'Ringing things', group: 'objects', motion: 'alarmRing', members: ['☎️', '📞', '🛎️'], aliases: { '☎': '☎️', '🛎': '🛎️' }, decoration: 'sound' },
208
+ peekers: { label: 'Peeking faces', group: 'faces', motion: 'peek', members: ['🫣', '🙈', '🙉', '🙊'] },
209
+ screens: { label: 'Screens and documents', group: 'objects', motion: 'steady', members: ['💻', '🖥️', '📠', '🧾', '📋'], aliases: { '🖥': '🖥️' }, decoration: 'scan' },
137
210
  swimmers: { label: 'Swimmers', group: 'people', motion: 'bob', members: gendered('🏊'), aliases: genderedToneAliases(gendered('🏊')), decoration: 'ripple' },
138
211
  surfers: { label: 'Surfers', group: 'people', motion: 'ride', members: gendered('🏄'), aliases: genderedToneAliases(gendered('🏄')), decoration: 'splash' },
139
212
  waterPolo: { label: 'Water polo', group: 'people', motion: 'bob', members: gendered('🤽'), aliases: genderedToneAliases(gendered('🤽')), decoration: 'splash' },
@@ -178,7 +251,7 @@ export const emoji = {
178
251
  '☕': { 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' },
179
252
  '🏀': { 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' },
180
253
  '💃': { group: 'people', name: 'Woman dancing', motion: 'twirl', skinTones: skinTones('💃') }, '🕺': { group: 'people', name: 'Man dancing', motion: 'twirl', skinTones: skinTones('🕺') },
181
- '⏳': { group: 'objects', name: 'Hourglass', motion: 'hourglassTurn' }, '⏰': { group: 'objects', name: 'Alarm clock', motion: 'shake' }, '🕯️': { group: 'objects', name: 'Candle', motion: 'steady', decoration: 'flicker', variants: ['🕯'] },
254
+ '⏳': { group: 'objects', name: 'Hourglass', motion: 'hourglassTurn' }, '⏰': { group: 'objects', name: 'Alarm clock', motion: 'alarmRing', decoration: 'sound' }, '🕯️': { group: 'objects', name: 'Candle', motion: 'steady', decoration: 'flicker', variants: ['🕯'] },
182
255
 
183
256
  // Additional handcrafted entries share motions where their physical action fits.
184
257
  '😀': { group: 'faces', name: 'Grinning face', motion: 'bounce' }, '🤣': { group: 'faces', name: 'Rolling on the floor laughing', motion: 'shake' },
@@ -191,7 +264,7 @@ export const emoji = {
191
264
  '🫰': { group: 'gestures', name: 'Hand with index finger and thumb crossed', motion: 'pop', skinTones: skinTones('🫰') },
192
265
  '🐱': { group: 'nature', name: 'Cat face', motion: 'nod' }, '🐻': { group: 'nature', name: 'Bear', motion: 'nod' }, '🐼': { group: 'nature', name: 'Panda', motion: 'sway' },
193
266
  '🐸': { group: 'nature', name: 'Frog', motion: 'hop' }, '🐧': { group: 'nature', name: 'Penguin', motion: 'sway' }, '🐥': { group: 'nature', name: 'Front facing baby chick', motion: 'hop' },
194
- '🐢': { group: 'nature', name: 'Turtle', motion: 'bob' }, '🐟': { group: 'nature', name: 'Fish', motion: 'bob' }, '🐬': { group: 'nature', name: 'Dolphin', motion: 'hop', decoration: 'splash' },
267
+ '🐢': { group: 'nature', name: 'Turtle', motion: 'peek' }, '🐟': { group: 'nature', name: 'Fish', motion: 'bob' }, '🐬': { group: 'nature', name: 'Dolphin', motion: 'hop', decoration: 'splash' },
195
268
  '🐙': { group: 'nature', name: 'Octopus', motion: 'twirl' }, '🌵': { group: 'nature', name: 'Cactus', motion: 'sway' }, '🌲': { group: 'nature', name: 'Evergreen tree', motion: 'sway' },
196
269
  '🌼': { group: 'nature', name: 'Blossom flower', motion: 'sway', decoration: 'petals' }, '❄️': { group: 'nature', name: 'Snowflake', motion: 'float', variants: ['❄'] }, '☁️': { group: 'nature', name: 'Cloud', motion: 'float', variants: ['☁'] },
197
270
  '🍎': { group: 'food', name: 'Red apple', motion: 'nod' }, '🍉': { group: 'food', name: 'Watermelon', motion: 'wobble' }, '🍓': { group: 'food', name: 'Strawberry', motion: 'bounce' },
@@ -202,7 +275,7 @@ export const emoji = {
202
275
  '🎨': { group: 'activities', name: 'Artist palette', motion: 'twirl' }, '🎸': { group: 'activities', name: 'Guitar', motion: 'sway', decoration: 'sound' },
203
276
  '🥁': { group: 'activities', name: 'Drum', motion: 'shake', decoration: 'sound' }, '🎮': { group: 'activities', name: 'Game controller', motion: 'wobble' }, '🪁': { group: 'activities', name: 'Kite', motion: 'flutter' },
204
277
  '🙋': { group: 'people', name: 'Person raising hand', motion: 'wave', skinTones: skinTones('🙋') }, '🧘': { group: 'people', name: 'Person in lotus position', motion: 'breathe', skinTones: skinTones('🧘') },
205
- '🏃': { group: 'people', name: 'Person running', motion: 'hop', skinTones: skinTones('🏃') }, '🤸': { group: 'people', name: 'Person cartwheeling', motion: 'twirl', skinTones: skinTones('🤸') },
278
+ '🏃': { group: 'people', name: 'Person running', motion: 'hop', skinTones: skinTones('🏃') }, '🤸': { group: 'people', name: 'Person cartwheeling', motion: 'somersault', skinTones: skinTones('🤸') },
206
279
  '🧑‍🍳': { group: 'people', name: 'Cook', motion: 'nod', skinTones: personSkinTones('🍳') }, '🧑‍🎨': { group: 'people', name: 'Artist', motion: 'twirl', skinTones: personSkinTones('🎨') },
207
280
  '🧑‍🚀': { group: 'people', name: 'Astronaut', motion: 'float', skinTones: personSkinTones('🚀') }, '🧑‍💻': { group: 'people', name: 'Technologist', motion: 'nod', skinTones: personSkinTones('💻') },
208
281
  '📱': { group: 'objects', name: 'Mobile phone', motion: 'shake' }, '📚': { group: 'objects', name: 'Books', motion: 'nod' }, '✉️': { group: 'objects', name: 'Envelope', motion: 'bob', variants: ['✉'] },
@@ -229,41 +302,63 @@ export const emoji = {
229
302
  '🤿': { group: 'activities', name: 'Diving mask', motion: 'underwater', decoration: 'bubbles' },
230
303
  '🛟': { group: 'travel', name: 'Ring buoy', motion: 'vesselRock', decoration: 'wake' },
231
304
  '🪙': { group: 'objects', name: 'Coin', motion: 'flipHorizontal' },
232
- '🎲': { group: 'activities', name: 'Game die', motion: 'spin' }
305
+ '🔨': { group: 'objects', name: 'Hammer', motion: 'slam', decoration: 'impact' },
306
+ '🔢': { group: 'symbols', name: 'Input numbers', motion: 'toss' },
307
+ '🪃': { group: 'activities', name: 'Boomerang', motion: 'toss' },
308
+ '🥏': { group: 'activities', name: 'Flying disc', motion: 'toss' },
309
+ '📳': { group: 'symbols', name: 'Vibration mode', motion: 'alarmRing' },
310
+ '🎲': { group: 'activities', name: 'Game die', motion: 'toss' }
233
311
  };
234
312
 
235
- // Declarative SVG layers never alter the native glyph. Anchors are relative to
236
- // the consumer's glyph container and may be overridden for a particular font.
237
- 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' } }] };
313
+ // Declarative SVG layers never alter the native glyph. Colors read a CSS custom
314
+ // property such as --emoji-motion-water and fall back to the listed value.
315
+ // Anchors are relative to the consumer's glyph container and may be overridden
316
+ // for a particular font.
317
+ const signalShape = { viewBox: '0 0 100 100', elements: [{ tag: 'circle', attrs: { cx: '50', cy: '50', r: '29', fill: 'none', stroke: 'var(--emoji-motion-signal, #7c8ee8)', 'stroke-width': '2.5' } }] };
238
318
  const notesShape = { viewBox: '0 0 100 100', elements: [
239
- { 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' } },
240
- { 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' } }
241
- ] };
242
- const sparkleShape = { viewBox: '0 0 100 100', elements: [
243
- { 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' } },
244
- { 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' } }
319
+ { 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: 'var(--emoji-motion-note, #b780c8)' } },
320
+ { 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: 'var(--emoji-motion-note-accent, #dc8f72)' } }
245
321
  ] };
246
322
  const centered = { width: '160%', height: '160%', overflow: 'visible' };
323
+ // A four point star of radius r centered on (cx, cy) in viewBox units.
324
+ const star = (cx, cy, r) => { const k = r / 12; const f = value => +(value * k).toFixed(2); return `M${cx} ${cy - r}c${f(1.5)} ${f(9)} ${f(3)} ${f(10.5)} ${f(12)} ${f(12)}-${f(9)} ${f(1.5)}-${f(10.5)} ${f(3)}-${f(12)} ${f(12)}-${f(1.5)}-${f(9)}-${f(3)}-${f(10.5)}-${f(12)}-${f(12)} ${f(9)}-${f(1.5)} ${f(10.5)}-${f(3)} ${f(12)}-${f(12)}z`; };
325
+ // Sparkles burst one after another: a quick pop outwards, then a slower drift as they fade.
326
+ const sparkleBurst = [[24, 24, 9, -7, -7, 'sparkle'], [78, 28, 6, 6, -6, 'sparkle-accent'], [82, 70, 7, 6, 5, 'sparkle'], [20, 66, 5, -6, 3, 'sparkle-accent']].map(([cx, cy, r, dx, dy, color], index) => ({
327
+ svg: { viewBox: '0 0 100 100', elements: [{ tag: 'path', attrs: { d: star(cx, cy, r), fill: `var(--emoji-motion-${color}, ${color === 'sparkle' ? '#e0a63e' : '#d787ad'})` } }] },
328
+ style: { ...centered, transformOrigin: `${cx}% ${cy}%` },
329
+ keyframes: [
330
+ { opacity: 0, transform: 'translate(-50%, -50%) scale(.3) rotate(0deg)', easing: 'cubic-bezier(.2,.8,.3,1)' },
331
+ { opacity: 1, transform: `translate(${-50 + dx * .4}%, ${-50 + dy * .4}%) scale(1.1) rotate(20deg)`, offset: .3, easing: 'ease-out' },
332
+ { opacity: 0, transform: `translate(${-50 + dx}%, ${-50 + dy}%) scale(.4) rotate(45deg)` }
333
+ ],
334
+ options: { duration: 700, delay: index * 60, easing: 'linear', fill: 'backwards' }
335
+ }));
336
+ // The scan line sweeps down, rests a beat at the bottom, then glides back and fades.
337
+ const scanShape = { viewBox: '0 0 100 100', elements: [
338
+ { tag: 'line', attrs: { x1: '24', y1: '22', x2: '76', y2: '22', stroke: 'var(--emoji-motion-scan, #76b6a4)', 'stroke-width': '7', 'stroke-linecap': 'round', opacity: '.25' } },
339
+ { tag: 'line', attrs: { x1: '24', y1: '22', x2: '76', y2: '22', stroke: 'var(--emoji-motion-scan, #76b6a4)', 'stroke-width': '2.5', 'stroke-linecap': 'round' } }
340
+ ] };
247
341
  const steamShape = { viewBox: '0 0 100 100', elements: [
248
- { 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' } }
342
+ { 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: 'var(--emoji-motion-steam, #a29aa5)', 'stroke-width': '2.5', 'stroke-linecap': 'round' } }
249
343
  ] };
250
344
  // Water effects sit at the waterline below the glyph and spread symmetrically.
251
345
  // y is the waterline in viewBox units: at the body for swimmers, below the hull for boats.
252
- const rippleRings = y => [0, 320].map(delay => ({
253
- svg: { viewBox: '0 0 100 100', elements: [{ tag: 'ellipse', attrs: { cx: '50', cy: String(y), rx: '30', ry: '6', fill: 'none', stroke: '#7cafd0', 'stroke-width': '2.5' } }] },
346
+ const rippleRings = (y, behind = false) => [0, 320].map(delay => ({
347
+ behind,
348
+ svg: { viewBox: '0 0 100 100', elements: [{ tag: 'ellipse', attrs: { cx: '50', cy: String(y), rx: '30', ry: '6', fill: 'none', stroke: 'var(--emoji-motion-water, #7cafd0)', 'stroke-width': '2.5' } }] },
254
349
  style: { ...centered, transformOrigin: `50% ${y}%` },
255
350
  keyframes: [{ opacity: .8, transform: 'translate(-50%, -50%) scale(.5)' }, { opacity: 0, transform: 'translate(-50%, -50%) scale(1.25)' }],
256
351
  options: { duration: 1100, delay, easing: 'cubic-bezier(.25,.6,.4,1)', fill: 'backwards' }
257
352
  }));
258
353
  const splashShape = { viewBox: '0 0 100 100', elements: [
259
- { tag: 'path', attrs: { d: 'M39 70l-9-8M45 67l-3-10M61 70l9-8M55 67l3-10', fill: 'none', stroke: '#7cafd0', 'stroke-width': '2.5', 'stroke-linecap': 'round' } },
260
- { tag: 'circle', attrs: { cx: '25', cy: '56', r: '2.6', fill: '#7cafd0' } },
261
- { tag: 'circle', attrs: { cx: '39', cy: '50', r: '2', fill: '#7cafd0' } },
262
- { tag: 'circle', attrs: { cx: '75', cy: '56', r: '2.6', fill: '#7cafd0' } },
263
- { tag: 'circle', attrs: { cx: '61', cy: '50', r: '2', fill: '#7cafd0' } }
354
+ { tag: 'path', attrs: { d: 'M39 70l-9-8M45 67l-3-10M61 70l9-8M55 67l3-10', fill: 'none', stroke: 'var(--emoji-motion-water, #7cafd0)', 'stroke-width': '2.5', 'stroke-linecap': 'round' } },
355
+ { tag: 'circle', attrs: { cx: '25', cy: '56', r: '2.6', fill: 'var(--emoji-motion-water, #7cafd0)' } },
356
+ { tag: 'circle', attrs: { cx: '39', cy: '50', r: '2', fill: 'var(--emoji-motion-water, #7cafd0)' } },
357
+ { tag: 'circle', attrs: { cx: '75', cy: '56', r: '2.6', fill: 'var(--emoji-motion-water, #7cafd0)' } },
358
+ { tag: 'circle', attrs: { cx: '61', cy: '50', r: '2', fill: 'var(--emoji-motion-water, #7cafd0)' } }
264
359
  ] };
265
360
  const rainShape = { viewBox: '0 0 100 100', elements: [
266
- { tag: 'path', attrs: { d: 'M30 67v6M50 72v6M70 67v6', fill: 'none', stroke: '#7c9fca', 'stroke-width': '3', 'stroke-linecap': 'round' } }
361
+ { tag: 'path', attrs: { d: 'M30 67v6M50 72v6M70 67v6', fill: 'none', stroke: 'var(--emoji-motion-water, #7c9fca)', 'stroke-width': '3', 'stroke-linecap': 'round' } }
267
362
  ] };
268
363
  // Symmetric rays sit around the glyph rather than assuming a bulb, lens or flame position.
269
364
  const lightShape = color => ({ viewBox: '0 0 100 100', elements: [
@@ -271,30 +366,31 @@ const lightShape = color => ({ viewBox: '0 0 100 100', elements: [
271
366
  ] });
272
367
  export const decorations = {
273
368
  bubbles: { anchor: { x: '50%', y: '50%' }, layers: [{ svg: { viewBox: '0 0 100 100', elements: [
274
- { tag: 'circle', attrs: { cx: '21', cy: '46', r: '3', fill: 'none', stroke: '#7cafd0', 'stroke-width': '2' } },
275
- { tag: 'circle', attrs: { cx: '76', cy: '38', r: '4', fill: 'none', stroke: '#7cafd0', 'stroke-width': '2' } },
276
- { tag: 'circle', attrs: { cx: '80', cy: '60', r: '2', fill: 'none', stroke: '#7cafd0', 'stroke-width': '2' } }
369
+ { tag: 'circle', attrs: { cx: '21', cy: '46', r: '3', fill: 'none', stroke: 'var(--emoji-motion-water, #7cafd0)', 'stroke-width': '2' } },
370
+ { tag: 'circle', attrs: { cx: '76', cy: '38', r: '4', fill: 'none', stroke: 'var(--emoji-motion-water, #7cafd0)', 'stroke-width': '2' } },
371
+ { tag: 'circle', attrs: { cx: '80', cy: '60', r: '2', fill: 'none', stroke: 'var(--emoji-motion-water, #7cafd0)', 'stroke-width': '2' } }
277
372
  ] }, style: centered, keyframes: [{ opacity: 0, transform: 'translate(-50%, -43%)' }, { opacity: .85, transform: 'translate(-50%, -50%)', offset: .35 }, { opacity: 0, transform: 'translate(-50%, -64%)' }], options: { duration: 1100, easing: curve } }] },
278
- alertLight: { anchor: { x: '50%', y: '50%' }, layers: [{ svg: lightShape('#df625e'), style: centered,
373
+ alertLight: { anchor: { x: '50%', y: '50%' }, layers: [{ svg: lightShape('var(--emoji-motion-alert, #df625e)'), style: centered,
279
374
  keyframes: [{ opacity: 0, transform: 'translate(-50%, -50%) scale(.85)' }, { opacity: .9, transform: 'translate(-50%, -50%) scale(1)', offset: .3 }, { opacity: 0, transform: 'translate(-50%, -50%) scale(1.12)' }],
280
375
  options: { duration: 900, easing: 'cubic-bezier(.22,1,.36,1)' }
281
376
  }] },
282
- glow: { anchor: { x: '50%', y: '50%' }, layers: [{ svg: lightShape('#e0a63e'), style: centered,
377
+ glow: { anchor: { x: '50%', y: '50%' }, layers: [{ svg: lightShape('var(--emoji-motion-glow, #e0a63e)'), style: centered,
283
378
  keyframes: [{ opacity: 0, transform: 'translate(-50%, -50%) scale(.85)' }, { opacity: .9, transform: 'translate(-50%, -50%) scale(1)', offset: .35 }, { opacity: 0, transform: 'translate(-50%, -50%) scale(1.08)' }],
284
379
  options: { duration: 850, easing: 'cubic-bezier(.22,1,.36,1)' }
285
380
  }] },
286
- flicker: { anchor: { x: '50%', y: '50%' }, layers: [{ svg: lightShape('#df985c'), style: centered,
381
+ flicker: { anchor: { x: '50%', y: '50%' }, layers: [{ svg: lightShape('var(--emoji-motion-flame, #df985c)'), style: centered,
287
382
  keyframes: [{ opacity: 0, transform: 'translate(-50%, -50%) scale(.9)' }, { opacity: .7, transform: 'translate(-50%, -50%) scale(1)', offset: .3 }, { opacity: .3, transform: 'translate(-50%, -50%) scale(.95)', offset: .5 }, { opacity: .75, transform: 'translate(-50%, -50%) scale(1.03)', offset: .7 }, { opacity: 0, transform: 'translate(-50%, -50%) scale(1)' }],
288
383
  options: { duration: 1100, easing: 'cubic-bezier(.22,1,.36,1)' }
289
384
  }] },
290
- flash: { anchor: { x: '50%', y: '50%' }, layers: [{ svg: lightShape('#a5b7df'), style: centered,
385
+ flash: { anchor: { x: '50%', y: '50%' }, layers: [{ svg: lightShape('var(--emoji-motion-flash, #a5b7df)'), style: centered,
291
386
  keyframes: [{ opacity: 0, transform: 'translate(-50%, -50%) scale(.75)' }, { opacity: 1, transform: 'translate(-50%, -50%) scale(1)', offset: .18 }, { opacity: 0, transform: 'translate(-50%, -50%) scale(1.2)', offset: .52 }, { opacity: 0, transform: 'translate(-50%, -50%) scale(1.2)' }],
292
387
  options: { duration: 800, easing: 'cubic-bezier(.22,1,.36,1)' }
293
388
  }] },
294
- 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,
295
- 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)' }
389
+ 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: 'var(--emoji-motion-impact, #d49b70)', 'stroke-width': '3', 'stroke-linecap': 'round' } }] }, style: centered,
390
+ // A linear timeline keeps the burst on the landing frame instead of drifting early.
391
+ keyframes: [{ opacity: 0, transform: 'translate(-50%, -50%) scale(.8)' }, { opacity: 0, transform: 'translate(-50%, -50%) scale(.8)', offset: .54, easing: 'ease-out' }, { opacity: .9, transform: 'translate(-50%, -50%) scale(1)', offset: .62, easing: settle }, { opacity: 0, transform: 'translate(-50%, -50%) scale(1.12)' }], options: { duration: 900, easing: 'linear' }
296
392
  }] },
297
- 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,
393
+ 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: 'var(--emoji-motion-petal, #d89cb9)' } }, { tag: 'ellipse', attrs: { cx: '76', cy: '38', rx: '3', ry: '5', fill: 'var(--emoji-motion-petal, #d89cb9)' } }] }, style: centered,
298
394
  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)' }
299
395
  }] },
300
396
  steam: { anchor: { x: '50%', y: '50%' }, layers: [{ svg: steamShape, style: centered,
@@ -306,7 +402,8 @@ export const decorations = {
306
402
  options: { duration: 950, easing: 'cubic-bezier(.22,1,.36,1)' }
307
403
  }] },
308
404
  ripple: { anchor: { x: '50%', y: '50%' }, layers: rippleRings(66) },
309
- wake: { anchor: { x: '50%', y: '50%' }, layers: rippleRings(75) },
405
+ // The hull hides the far side of its wake.
406
+ wake: { anchor: { x: '50%', y: '50%' }, layers: rippleRings(75, true) },
310
407
  splash: { anchor: { x: '50%', y: '50%' }, layers: [{ svg: splashShape, style: { ...centered, transformOrigin: '50% 70%' },
311
408
  keyframes: [{ opacity: 0, transform: 'translate(-50%, -50%) scale(.55)' }, { opacity: .9, transform: 'translate(-50%, -50%) scale(1)', offset: .35 }, { opacity: 0, transform: 'translate(-50%, -54%) scale(1.15)' }],
312
409
  options: { duration: 850, easing: curve }
@@ -326,11 +423,15 @@ export const decorations = {
326
423
  options: { duration: 900, easing: 'cubic-bezier(.22,1,.36,1)' }
327
424
  }]
328
425
  },
329
- sparkle: {
330
- anchor: { x: '50%', y: '50%' },
331
- layers: [{ svg: sparkleShape, style: centered,
332
- 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)' }],
333
- options: { duration: 850, easing: 'cubic-bezier(.22,1,.36,1)' }
334
- }]
335
- }
426
+ sparkle: { anchor: { x: '50%', y: '50%' }, layers: sparkleBurst },
427
+ scan: { anchor: { x: '50%', y: '50%' }, layers: [{ svg: scanShape, style: centered,
428
+ keyframes: [
429
+ { opacity: 0, transform: 'translate(-50%, -50%)', easing: 'ease-out' },
430
+ { opacity: 1, transform: 'translate(-50%, -48%)', offset: .1, easing: 'ease-in-out' },
431
+ { opacity: 1, transform: 'translate(-50%, -14%)', offset: .5, easing: 'linear' },
432
+ { opacity: 1, transform: 'translate(-50%, -14%)', offset: .62, easing: 'ease-in-out' },
433
+ { opacity: 0, transform: 'translate(-50%, -40%)' }
434
+ ],
435
+ options: { duration: 900, easing: 'linear' }
436
+ }] }
336
437
  };
package/src/react-core.js CHANGED
@@ -14,7 +14,7 @@ export function createEmojiMotionComponent(emojiMotion) {
14
14
  return registry.bind(glyph.current, emoji, { trigger: triggerRef?.current ?? wrapper.current, events, skinTone, ...(decoration ? { decoration: { container: visual.current } } : {}) });
15
15
  }, [registry, emoji, skinTone, decoration, events, triggerRef]);
16
16
  return createElement('span', { ...attributes, className, ref: wrapper, 'data-emoji-motion': 'component', role: 'img', 'aria-label': label ?? result?.name ?? emoji, tabIndex: attributes.tabIndex ?? (triggerRef ? -1 : 0), style },
17
- createElement('span', { ref: visual, style: { display: 'inline-block', position: 'relative' } },
17
+ createElement('span', { ref: visual, style: { display: 'inline-block', position: 'relative', isolation: 'isolate' } },
18
18
  createElement('span', { ref: glyph, 'aria-hidden': 'true', style: { display: 'inline-block' } }, result?.glyph ?? emoji)));
19
19
  }
20
20
  }
package/src/registry.js CHANGED
@@ -159,11 +159,17 @@ export function createRegistry(config = {}, classifier) {
159
159
  node.setAttribute('viewBox', layer.svg.viewBox);
160
160
  for (const shape of layer.svg.elements) {
161
161
  const child = container.ownerDocument.createElementNS('http://www.w3.org/2000/svg', shape.tag);
162
- for (const [key, value] of Object.entries(shape.attrs)) child.setAttribute(key, value);
162
+ for (const [key, value] of Object.entries(shape.attrs)) {
163
+ // Presentation attributes cannot read custom properties, so a themed
164
+ // color goes through style and keeps its fallback as the attribute.
165
+ const themed = typeof value === 'string' && value.match(/^var\(\s*--[\w-]+\s*,\s*(.+)\)$/);
166
+ child.setAttribute(key, themed ? themed[1].trim() : value);
167
+ if (themed) child.style[key] = value;
168
+ }
163
169
  node.appendChild(child);
164
170
  }
165
171
  }
166
- Object.assign(node.style, { position: 'absolute', left: '0', top: '0', opacity: '0', ...layer.style });
172
+ Object.assign(node.style, { position: 'absolute', left: '0', top: '0', opacity: '0', ...(layer.behind ? { zIndex: '-1' } : {}), ...layer.style });
167
173
  decorationRoot.appendChild(node);
168
174
  decorationAnimations.push(node.animate(layer.keyframes, layer.options));
169
175
  }
@@ -176,7 +182,21 @@ export function createRegistry(config = {}, classifier) {
176
182
  function bind(element, glyph, options = {}) {
177
183
  const trigger = options.trigger ?? element;
178
184
  const events = options.events ?? ['pointerenter', 'focus'];
179
- const handler = () => play(element, glyph, options);
185
+ const cooldown = options.cooldown ?? 260;
186
+ let lastStart = -Infinity;
187
+ const handler = event => {
188
+ // A tap fires pointerenter and click together; let the click play it once.
189
+ if (event?.type === 'pointerenter' && event.pointerType === 'touch') return;
190
+ // A mouse click also focuses the trigger; only keyboard focus replays.
191
+ if ((event?.type === 'focus' || event?.type === 'focusin') && typeof event.target?.matches === 'function') {
192
+ try { if (!event.target.matches(':focus-visible')) return; } catch { /* Older engines without :focus-visible. */ }
193
+ }
194
+ // Quick repeated triggers would restart the motion before it reads.
195
+ const now = globalThis.performance?.now?.() ?? Date.now();
196
+ if (now - lastStart < cooldown) return;
197
+ lastStart = now;
198
+ play(element, glyph, options);
199
+ };
180
200
  for (const event of events) trigger.addEventListener(event, handler);
181
201
  return () => { for (const event of events) trigger.removeEventListener(event, handler); active.get(element)?.dispose(); active.delete(element); };
182
202
  }
package/src/vue-core.js CHANGED
@@ -22,7 +22,7 @@ export function createEmojiMotionComponent(emojiMotion) {
22
22
  return () => {
23
23
  const result = props.registry.resolve(props.emoji, { skinTone: props.skinTone });
24
24
  return h('span', { ...attrs, ref: wrapper, 'data-emoji-motion': 'component', role: 'img', 'aria-label': props.label ?? result?.name ?? props.emoji, tabindex: attrs.tabindex ?? (props.trigger ? -1 : 0) },
25
- h('span', { ref: visual, style: { display: 'inline-block', position: 'relative' } },
25
+ h('span', { ref: visual, style: { display: 'inline-block', position: 'relative', isolation: 'isolate' } },
26
26
  h('span', { ref: glyph, 'aria-hidden': 'true', style: { display: 'inline-block' } }, result?.glyph ?? props.emoji)));
27
27
  };
28
28
  }