@dofy/native-emoji-motion 0.6.0 → 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,20 @@
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
+
12
+ ## 0.6.4 — 2026-11-14
13
+
14
+ - Add ripple, splash and wake effects for water.
15
+ - Add families for swimmers, surfers, water polo players and rowers, covering their gender and skin tone variants.
16
+ - Boats, the ring buoy, the water wave, the dolphin and the diving mask now use water effects.
17
+
3
18
  ## 0.6.0 — 2026-11-14
4
19
 
5
20
  - Add motion families for vegetables, land animals, clothing, tools, books, hand gestures, boats and sweet treats.
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.0 | 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 | 27 | 200 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 | 73 | 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.0",
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
 
@@ -101,11 +171,14 @@ const rolePeople = ['🧑', '👨', '👩'];
101
171
  const roleSuffixes = ['⚕️', '🎓', '🏫', '🌾', '🔧', '🔬', '🚒', '⚖️', '✈️', '🎤'];
102
172
  const roleMembers = roleSuffixes.flatMap(suffix => rolePeople.map(person => `${person}‍${suffix}`));
103
173
  const roleAliases = Object.fromEntries(roleSuffixes.flatMap(suffix => rolePeople.flatMap(person => Object.values(skinTones(person)).map(variant => [`${variant}‍${suffix}`, `${person}‍${suffix}`]))));
104
- const walkerMembers = ['🚶', '🚶‍♂️', '🚶‍♀️', '🏃‍♂️', '🏃‍♀️'];
105
- const walkerAliases = Object.fromEntries(walkerMembers.flatMap(glyph => {
174
+ // Gendered sequences take the modifier on the person, before the joiner.
175
+ const genderedToneAliases = glyphs => Object.fromEntries(glyphs.flatMap(glyph => {
106
176
  const [person, suffix = ''] = glyph.split('‍');
107
177
  return Object.values(skinTones(person)).map(variant => [suffix ? `${variant}‍${suffix}` : variant, glyph]);
108
178
  }));
179
+ const gendered = person => [person, `${person}‍♂️`, `${person}‍♀️`];
180
+ const walkerMembers = ['🚶', '🚶‍♂️', '🚶‍♀️', '🏃‍♂️', '🏃‍♀️'];
181
+ const walkerAliases = genderedToneAliases(walkerMembers);
109
182
  // Members are reviewed explicitly; no entire Unicode subgroup is assumed to move alike.
110
183
  export const families = {
111
184
  clocks: { label: 'Clocks', group: 'objects', motion: 'clockTick', members: ['⌚', '🕰️', '⏲️', '🕛', '🕧'], aliases: { '🕰': '🕰️', '⏲': '⏲️' } },
@@ -114,10 +187,10 @@ export const families = {
114
187
  vegetables: { label: 'Vegetables', group: 'food', motion: 'gardenBounce', members: ['🥕', '🥦', '🥬', '🥒', '🌽', '🫑', '🍆', '🧅', '🧄', '🥔'] },
115
188
  landAnimals: { label: 'Land animals', group: 'nature', motion: 'animalHop', members: ['🐕', '🐈', '🐖', '🐑', '🐐', '🐄', '🐎', '🦌', '🐘', '🦒'] },
116
189
  clothing: { label: 'Clothing', group: 'objects', motion: 'fabricSway', members: ['👕', '👖', '🧥', '👗', '🩳', '👚', '👔'] },
117
- handTools: { label: 'Hand tools', group: 'objects', motion: 'toolWiggle', members: ['🔨', '🪓', '🪚', '🪛', '🔧', '🪠', '🔩'] },
190
+ handTools: { label: 'Hand tools', group: 'objects', motion: 'toolWiggle', members: ['🪓', '🪚', '🪛', '🔧', '🪠', '🔩'] },
118
191
  books: { label: 'Books', group: 'objects', motion: 'bookPeek', members: ['📕', '📗', '📘', '📙', '📔', '📓', '📒'] },
119
192
  handGestures: { label: 'Hand gestures', group: 'gestures', motion: 'handEmphasis', members: ['🤏', '🫳', '🫴', '🫲', '🫱', '🫷', '🫸', '🤌', '🫵'], aliases: familyToneAliases(['🤏', '🫳', '🫴', '🫲', '🫱', '🫷', '🫸', '🤌', '🫵']) },
120
- vessels: { label: 'Boats', group: 'travel', motion: 'vesselRock', members: ['🚤', '🛥️', '🛳️', '⛴️', '🛶'], aliases: { '🛥': '🛥️', '🛳': '🛳️', '⛴': '⛴️' } },
193
+ vessels: { label: 'Boats', group: 'travel', motion: 'vesselRock', members: ['🚤', '🛥️', '🛳️', '⛴️', '🛶'], aliases: { '🛥': '🛥️', '🛳': '🛳️', '⛴': '⛴️' }, decoration: 'wake' },
121
194
  treats: { label: 'Sweet treats', group: 'food', motion: 'treatPop', members: ['🍬', '🍭', '🍫', '🍮', '🧁', '🍨'], decoration: 'sparkle' },
122
195
  fruit: { label: 'Fruit', group: 'food', motion: 'fruitBounce', members: ['🍊', '🍋', '🍐', '🍑', '🍇', '🫐', '🍒', '🥝', '🍍', '🥭'], decoration: 'sparkle' },
123
196
  animalFaces: { label: 'Animal faces', group: 'nature', motion: 'curiousTilt', members: ['🐶', '🦊', '🐹', '🦝', '🐨', '🐯', '🐭', '🐻‍❄️'] },
@@ -131,6 +204,13 @@ export const families = {
131
204
  poses: { label: 'People in poses', group: 'people', motion: 'pose', members: ['🧎', '🧍', '🙇'], aliases: familyToneAliases(['🧎', '🧍', '🙇']) },
132
205
  professionals: { label: 'Professionals', group: 'people', motion: 'pose', members: roleMembers, aliases: roleAliases },
133
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' },
210
+ swimmers: { label: 'Swimmers', group: 'people', motion: 'bob', members: gendered('🏊'), aliases: genderedToneAliases(gendered('🏊')), decoration: 'ripple' },
211
+ surfers: { label: 'Surfers', group: 'people', motion: 'ride', members: gendered('🏄'), aliases: genderedToneAliases(gendered('🏄')), decoration: 'splash' },
212
+ waterPolo: { label: 'Water polo', group: 'people', motion: 'bob', members: gendered('🤽'), aliases: genderedToneAliases(gendered('🤽')), decoration: 'splash' },
213
+ rowers: { label: 'Rowers', group: 'people', motion: 'vesselRock', members: gendered('🚣'), aliases: genderedToneAliases(gendered('🚣')), decoration: 'wake' },
134
214
  seaLife: { label: 'Sea life', group: 'nature', motion: 'underwater', members: ['🐠', '🐡', '🦈', '🐋', '🐳', '🦭'], decoration: 'bubbles' },
135
215
  bakery: { label: 'Bakery', group: 'food', motion: 'pastryBounce', members: ['🍞', '🥐', '🥖', '🥨', '🥯', '🥞', '🧇'] },
136
216
  rail: { label: 'Rail vehicles', group: 'travel', motion: 'railRide', members: ['🚄', '🚅', '🚆', '🚇', '🚈', '🚊', '🚝', '🚞'] },
@@ -163,15 +243,15 @@ export const emoji = {
163
243
  '🔍': { 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: ['⏱'] },
164
244
  '💡': { group: 'objects', name: 'Light bulb', motion: 'breathe', decoration: 'glow' }, '📷': { group: 'objects', name: 'Camera', motion: 'bounce', decoration: 'flash' }, '🔑': { group: 'objects', name: 'Key', motion: 'wobble' }, '📌': { group: 'objects', name: 'Pushpin', motion: 'bounce' }, '📦': { group: 'objects', name: 'Package', motion: 'shake' }, '🧭': { group: 'objects', name: 'Compass', motion: 'wobble' },
165
245
  '🌸': { group: 'nature', name: 'Blossom', decoration: 'petals' }, '🍃': { group: 'nature', name: 'Leaves' }, '☀️': { group: 'nature', name: 'Sun', motion: 'spin', decoration: 'glow', variants: ['☀'] },
166
- '🌻': { group: 'nature', name: 'Sunflower', motion: 'leafSway' }, '🌈': { 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', decoration: 'flicker' }, '🌊': { group: 'nature', name: 'Water wave', motion: 'float' },
246
+ '🌻': { group: 'nature', name: 'Sunflower', motion: 'leafSway' }, '🌈': { 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', decoration: 'flicker' }, '🌊': { group: 'nature', name: 'Water wave', motion: 'float', decoration: 'splash' },
167
247
  '🚀': { group: 'travel', name: 'Rocket' }, '✈️': { group: 'travel', name: 'Airplane', motion: 'glide', variants: ['✈'] }, '🚲': { group: 'travel', name: 'Bicycle', motion: 'ride' },
168
- '🚗': { 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' },
248
+ '🚗': { group: 'travel', name: 'Car', motion: 'ride' }, '🚂': { group: 'travel', name: 'Locomotive', motion: 'ride' }, '🛸': { group: 'travel', name: 'Flying saucer', motion: 'float' }, '🚢': { group: 'travel', name: 'Ship', motion: 'vesselRock', decoration: 'wake' },
169
249
  '✨': { 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' },
170
250
  '🐰': { 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: ['🌧'] },
171
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' },
172
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' },
173
253
  '💃': { group: 'people', name: 'Woman dancing', motion: 'twirl', skinTones: skinTones('💃') }, '🕺': { group: 'people', name: 'Man dancing', motion: 'twirl', skinTones: skinTones('🕺') },
174
- '⏳': { 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: ['🕯'] },
175
255
 
176
256
  // Additional handcrafted entries share motions where their physical action fits.
177
257
  '😀': { group: 'faces', name: 'Grinning face', motion: 'bounce' }, '🤣': { group: 'faces', name: 'Rolling on the floor laughing', motion: 'shake' },
@@ -184,7 +264,7 @@ export const emoji = {
184
264
  '🫰': { group: 'gestures', name: 'Hand with index finger and thumb crossed', motion: 'pop', skinTones: skinTones('🫰') },
185
265
  '🐱': { group: 'nature', name: 'Cat face', motion: 'nod' }, '🐻': { group: 'nature', name: 'Bear', motion: 'nod' }, '🐼': { group: 'nature', name: 'Panda', motion: 'sway' },
186
266
  '🐸': { group: 'nature', name: 'Frog', motion: 'hop' }, '🐧': { group: 'nature', name: 'Penguin', motion: 'sway' }, '🐥': { group: 'nature', name: 'Front facing baby chick', motion: 'hop' },
187
- '🐢': { group: 'nature', name: 'Turtle', motion: 'bob' }, '🐟': { group: 'nature', name: 'Fish', motion: 'bob' }, '🐬': { group: 'nature', name: 'Dolphin', motion: 'bob' },
267
+ '🐢': { group: 'nature', name: 'Turtle', motion: 'peek' }, '🐟': { group: 'nature', name: 'Fish', motion: 'bob' }, '🐬': { group: 'nature', name: 'Dolphin', motion: 'hop', decoration: 'splash' },
188
268
  '🐙': { group: 'nature', name: 'Octopus', motion: 'twirl' }, '🌵': { group: 'nature', name: 'Cactus', motion: 'sway' }, '🌲': { group: 'nature', name: 'Evergreen tree', motion: 'sway' },
189
269
  '🌼': { group: 'nature', name: 'Blossom flower', motion: 'sway', decoration: 'petals' }, '❄️': { group: 'nature', name: 'Snowflake', motion: 'float', variants: ['❄'] }, '☁️': { group: 'nature', name: 'Cloud', motion: 'float', variants: ['☁'] },
190
270
  '🍎': { group: 'food', name: 'Red apple', motion: 'nod' }, '🍉': { group: 'food', name: 'Watermelon', motion: 'wobble' }, '🍓': { group: 'food', name: 'Strawberry', motion: 'bounce' },
@@ -195,15 +275,14 @@ export const emoji = {
195
275
  '🎨': { group: 'activities', name: 'Artist palette', motion: 'twirl' }, '🎸': { group: 'activities', name: 'Guitar', motion: 'sway', decoration: 'sound' },
196
276
  '🥁': { group: 'activities', name: 'Drum', motion: 'shake', decoration: 'sound' }, '🎮': { group: 'activities', name: 'Game controller', motion: 'wobble' }, '🪁': { group: 'activities', name: 'Kite', motion: 'flutter' },
197
277
  '🙋': { group: 'people', name: 'Person raising hand', motion: 'wave', skinTones: skinTones('🙋') }, '🧘': { group: 'people', name: 'Person in lotus position', motion: 'breathe', skinTones: skinTones('🧘') },
198
- '🏃': { group: 'people', name: 'Person running', motion: 'hop', skinTones: skinTones('🏃') }, '🤸': { group: 'people', name: 'Person cartwheeling', motion: 'twirl', skinTones: skinTones('🤸') },
199
- '🏊': { group: 'people', name: 'Person swimming', motion: 'bob', skinTones: skinTones('🏊') }, '🏄': { group: 'people', name: 'Person surfing', motion: 'bob', skinTones: skinTones('🏄') },
278
+ '🏃': { group: 'people', name: 'Person running', motion: 'hop', skinTones: skinTones('🏃') }, '🤸': { group: 'people', name: 'Person cartwheeling', motion: 'somersault', skinTones: skinTones('🤸') },
200
279
  '🧑‍🍳': { group: 'people', name: 'Cook', motion: 'nod', skinTones: personSkinTones('🍳') }, '🧑‍🎨': { group: 'people', name: 'Artist', motion: 'twirl', skinTones: personSkinTones('🎨') },
201
280
  '🧑‍🚀': { group: 'people', name: 'Astronaut', motion: 'float', skinTones: personSkinTones('🚀') }, '🧑‍💻': { group: 'people', name: 'Technologist', motion: 'nod', skinTones: personSkinTones('💻') },
202
281
  '📱': { group: 'objects', name: 'Mobile phone', motion: 'shake' }, '📚': { group: 'objects', name: 'Books', motion: 'nod' }, '✉️': { group: 'objects', name: 'Envelope', motion: 'bob', variants: ['✉'] },
203
282
  '💌': { group: 'objects', name: 'Love letter', motion: 'bob', decoration: 'sparkle' }, '🔒': { group: 'objects', name: 'Locked', motion: 'wobble' }, '🔓': { group: 'objects', name: 'Unlocked', motion: 'pop' },
204
283
  '🔦': { group: 'objects', name: 'Flashlight', motion: 'nod', decoration: 'flash' }, '🪄': { group: 'objects', name: 'Magic wand', motion: 'twirl', decoration: 'sparkle' }, '🎤': { group: 'objects', name: 'Microphone', motion: 'swing', decoration: 'sound' },
205
284
  '🚌': { group: 'travel', name: 'Bus', motion: 'ride' }, '🚕': { group: 'travel', name: 'Taxi', motion: 'ride' }, '🚁': { group: 'travel', name: 'Helicopter', motion: 'bob' },
206
- '🎡': { group: 'travel', name: 'Ferris wheel', motion: 'steady' }, '⛵': { group: 'travel', name: 'Sailboat', motion: 'bob' }, '🎠': { group: 'travel', name: 'Carousel horse', motion: 'carousel' },
285
+ '🎡': { group: 'travel', name: 'Ferris wheel', motion: 'steady' }, '⛵': { group: 'travel', name: 'Sailboat', motion: 'vesselRock', decoration: 'wake' }, '🎠': { group: 'travel', name: 'Carousel horse', motion: 'carousel' },
207
286
  // Direction-neutral lighting for other objects that naturally emit or reflect light.
208
287
  '📸': { group: 'objects', name: 'Camera with flash', motion: 'bounce', decoration: 'flash' },
209
288
  '🪔': { group: 'objects', name: 'Oil lamp', motion: 'sway', decoration: 'flicker' },
@@ -219,27 +298,67 @@ export const emoji = {
219
298
  '💎': { group: 'objects', name: 'Gem stone', motion: 'pulse', decoration: 'sparkle' },
220
299
  '🪩': { group: 'objects', name: 'Mirror ball', motion: 'spin', decoration: 'sparkle' },
221
300
  '🌠': { group: 'nature', name: 'Shooting star', motion: 'float', decoration: 'sparkle' },
301
+ // Water gear borrows the sea life and boat motions with matching water effects.
302
+ '🤿': { group: 'activities', name: 'Diving mask', motion: 'underwater', decoration: 'bubbles' },
303
+ '🛟': { group: 'travel', name: 'Ring buoy', motion: 'vesselRock', decoration: 'wake' },
222
304
  '🪙': { group: 'objects', name: 'Coin', motion: 'flipHorizontal' },
223
- '🎲': { 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' }
224
311
  };
225
312
 
226
- // Declarative SVG layers never alter the native glyph. Anchors are relative to
227
- // the consumer's glyph container and may be overridden for a particular font.
228
- 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' } }] };
229
318
  const notesShape = { viewBox: '0 0 100 100', elements: [
230
- { 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' } },
231
- { 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' } }
232
- ] };
233
- const sparkleShape = { viewBox: '0 0 100 100', elements: [
234
- { 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' } },
235
- { 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)' } }
236
321
  ] };
237
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
+ ] };
238
341
  const steamShape = { viewBox: '0 0 100 100', elements: [
239
- { 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' } }
343
+ ] };
344
+ // Water effects sit at the waterline below the glyph and spread symmetrically.
345
+ // y is the waterline in viewBox units: at the body for swimmers, below the hull for boats.
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' } }] },
349
+ style: { ...centered, transformOrigin: `50% ${y}%` },
350
+ keyframes: [{ opacity: .8, transform: 'translate(-50%, -50%) scale(.5)' }, { opacity: 0, transform: 'translate(-50%, -50%) scale(1.25)' }],
351
+ options: { duration: 1100, delay, easing: 'cubic-bezier(.25,.6,.4,1)', fill: 'backwards' }
352
+ }));
353
+ const splashShape = { viewBox: '0 0 100 100', elements: [
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)' } }
240
359
  ] };
241
360
  const rainShape = { viewBox: '0 0 100 100', elements: [
242
- { 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' } }
243
362
  ] };
244
363
  // Symmetric rays sit around the glyph rather than assuming a bulb, lens or flame position.
245
364
  const lightShape = color => ({ viewBox: '0 0 100 100', elements: [
@@ -247,30 +366,31 @@ const lightShape = color => ({ viewBox: '0 0 100 100', elements: [
247
366
  ] });
248
367
  export const decorations = {
249
368
  bubbles: { anchor: { x: '50%', y: '50%' }, layers: [{ svg: { viewBox: '0 0 100 100', elements: [
250
- { tag: 'circle', attrs: { cx: '21', cy: '46', r: '3', fill: 'none', stroke: '#7cafd0', 'stroke-width': '2' } },
251
- { tag: 'circle', attrs: { cx: '76', cy: '38', r: '4', fill: 'none', stroke: '#7cafd0', 'stroke-width': '2' } },
252
- { 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' } }
253
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 } }] },
254
- 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,
255
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)' }],
256
375
  options: { duration: 900, easing: 'cubic-bezier(.22,1,.36,1)' }
257
376
  }] },
258
- 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,
259
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)' }],
260
379
  options: { duration: 850, easing: 'cubic-bezier(.22,1,.36,1)' }
261
380
  }] },
262
- 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,
263
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)' }],
264
383
  options: { duration: 1100, easing: 'cubic-bezier(.22,1,.36,1)' }
265
384
  }] },
266
- 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,
267
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)' }],
268
387
  options: { duration: 800, easing: 'cubic-bezier(.22,1,.36,1)' }
269
388
  }] },
270
- 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,
271
- 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' }
272
392
  }] },
273
- 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,
274
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)' }
275
395
  }] },
276
396
  steam: { anchor: { x: '50%', y: '50%' }, layers: [{ svg: steamShape, style: centered,
@@ -281,6 +401,13 @@ export const decorations = {
281
401
  keyframes: [{ opacity: 0, transform: 'translate(-50%, -55%)' }, { opacity: .9, transform: 'translate(-50%, -50%)', offset: .3 }, { opacity: 0, transform: 'translate(-50%, -40%)' }],
282
402
  options: { duration: 950, easing: 'cubic-bezier(.22,1,.36,1)' }
283
403
  }] },
404
+ ripple: { anchor: { x: '50%', y: '50%' }, layers: rippleRings(66) },
405
+ // The hull hides the far side of its wake.
406
+ wake: { anchor: { x: '50%', y: '50%' }, layers: rippleRings(75, true) },
407
+ splash: { anchor: { x: '50%', y: '50%' }, layers: [{ svg: splashShape, style: { ...centered, transformOrigin: '50% 70%' },
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)' }],
409
+ options: { duration: 850, easing: curve }
410
+ }] },
284
411
  signal: {
285
412
  anchor: { x: '50%', y: '50%' },
286
413
  layers: [0, 180, 360].map(delay => ({
@@ -296,11 +423,15 @@ export const decorations = {
296
423
  options: { duration: 900, easing: 'cubic-bezier(.22,1,.36,1)' }
297
424
  }]
298
425
  },
299
- sparkle: {
300
- anchor: { x: '50%', y: '50%' },
301
- layers: [{ svg: sparkleShape, style: centered,
302
- 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)' }],
303
- options: { duration: 850, easing: 'cubic-bezier(.22,1,.36,1)' }
304
- }]
305
- }
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
+ }] }
306
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
  }