@dofy/native-emoji-motion 0.2.0 → 0.4.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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 — 2026-11-13
4
+
5
+ - **Breaking:** Remove the component `display` prop and web component attribute. Components use normal inline layout; use a block container or CSS for placement.
6
+ - Add `enhance()` to animate emoji in existing text and observe dynamic content.
7
+ - Expand reviewed motion families and improve the demo.
8
+
9
+ ## 0.3.0 — 2026-11-13
10
+
11
+ - Add lighting and sparkle effects for light sources, reflective objects, and celebrations.
12
+ - Expand the handcrafted collection to 177 emoji, including 44 with optional SVG effects.
13
+ - Refine motions for amusement rides, dice, compass, sunflower, candle, and hourglass.
14
+ - Add reusable 3D carousel and object flip motions.
15
+
3
16
  ## 0.2.0 — 2026-11-13
4
17
 
5
18
  - Expand the handcrafted collection to 161 emoji and add five reviewed motion families.
package/README.md CHANGED
@@ -1,256 +1,56 @@
1
- # Emoji Motion
1
+ <p align="center">
2
+ <img src="./assets/logo.svg" width="104" height="104" alt="Emoji Motion logo" />
3
+ </p>
2
4
 
3
- Small, expressive animations for native emoji. A framework independent library with Unicode based automatic classification, reusable motion presets and explicit per emoji overrides.
5
+ <h1 align="center">Emoji Motion</h1>
4
6
 
5
- ## Collection at a glance
7
+ <p align="center">
8
+ <strong>Give your emoji a little more feeling. ✨</strong><br />
9
+ Small, expressive animations for the native emoji you already use.
10
+ </p>
6
11
 
7
- The current repository and demo contain **161 handcrafted emoji presets** and **5 reviewed motion families** with **24 additional base emoji**, across **10 categories**. They use **27 reusable motions**; **24 base emoji** have optional SVG effects. Skin tones and other variants are additional sequences, and similar emoji may share the same motion.
12
+ <p align="center">
13
+ <a href="https://www.npmjs.com/package/@dofy/native-emoji-motion"><img src="https://img.shields.io/npm/v/%40dofy%2Fnative-emoji-motion?label=npm" alt="npm version" /></a>
14
+ <a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license" /></a>
15
+ <a href="https://www.npmjs.com/package/@dofy/native-emoji-motion"><img src="https://img.shields.io/node/v/%40dofy%2Fnative-emoji-motion" alt="Node.js version" /></a>
16
+ </p>
8
17
 
9
- The default entry also recognizes **5,235 Unicode Emoji 18.0 sequences**, including qualified forms and variants, and applies a category based fallback when there is no handcrafted preset or reviewed family match. These collection counts describe version 0.2.0.
18
+ Bring 👋, ❤️, 📡 and more to life in React, Vue, Svelte or plain HTML. Emoji Motion animates your system's native emoji, with reusable motions, optional decorative effects and room for your own ideas. Explore the collection and try any emoji in the [live demo](https://emoji-motion.phpz.org/).
10
19
 
11
- ## Try the demo
20
+ - 🎨 **Native and adaptable:** keep the platform's emoji style, with skin tone support and optional SVG effects.
21
+ - 🧩 **Easy to use:** drop a component into a sentence or use it as a block.
22
+ - 🛠️ **Made to extend:** customize individual emoji, motion families and classification rules.
23
+ - 🌍 **Ready for more:** Unicode-based automatic classification provides a fallback for recognized emoji.
12
24
 
13
- [Explore the live demo](https://emoji-motion.phpz.org/) or run it locally:
25
+ ## 📊 Collection at a glance
14
26
 
15
- ```sh
16
- npm run dev
17
- ```
27
+ | In version 0.4.0 | Count | What it covers |
28
+ | --- | ---: | --- |
29
+ | Handcrafted presets | 177 | Individually configured base emoji |
30
+ | Reviewed motion families | 11 | 78 additional base emoji sharing reviewed motions |
31
+ | Categories | 10 | Groups used to organize the collection |
32
+ | Reusable motions | 37 | Motions shared by presets and families |
33
+ | Emoji with optional SVG effects | 57 | Base emoji with decorative effects |
34
+ | Recognized Unicode sequences | 5,235 | Unicode Emoji 18.0 sequences, including skin tones and other variants |
18
35
 
19
- Open http://127.0.0.1:4173. Hover, focus or tap a card; use the filters to explore the groups. The demo imports the same public entry point as an application. Use **Try any emoji** to preview automatic classification. The gallery starts with a curated selection from across the groups. Choose **All presets** for the handcrafted list, **Motion families** for reviewed family members, **With effects** for decorative SVG layers, or select a semantic group. Choose a framework and display mode, then select an emoji to copy its component. The examples show how to place an emoji in a sentence or on its own line.
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.
20
37
 
21
- ## Installation
38
+ ## 📦 Get started
22
39
 
23
40
  ```sh
24
41
  npm install @dofy/native-emoji-motion
25
42
  ```
26
43
 
27
- The package is ESM and uses the browser Web Animations API. React, Vue and Svelte are optional peers: install the framework used by your application.
28
-
29
- ## Components
30
-
31
- Use an emoji directly as a component. `display` is `inline` by default and can be set to `block`; all components animate on hover, focus and click and use built in SVG effects when available. Use these imports in a bundled app. React, Vue and Svelte are optional peers, so the core stays framework independent.
32
-
33
44
  ```jsx
34
45
  import { EmojiMotion } from '@dofy/native-emoji-motion/react';
35
46
 
36
47
  <p>Hello <EmojiMotion emoji="👋" skinTone="medium" /></p>
37
- <EmojiMotion emoji="📡" display="block" />
38
- ```
39
-
40
- ```vue
41
- <script setup>
42
- import { EmojiMotion } from '@dofy/native-emoji-motion/vue';
43
- </script>
44
- <template>
45
- <p>Hello <EmojiMotion emoji="👋" skin-tone="medium" /></p>
46
- <EmojiMotion emoji="📡" display="block" />
47
- </template>
48
- ```
49
-
50
- ```svelte
51
- <script>
52
- import EmojiMotion from '@dofy/native-emoji-motion/svelte/EmojiMotion.svelte';
53
- </script>
54
- <p>Hello <EmojiMotion emoji="👋" skinTone="medium" /></p>
55
- <EmojiMotion emoji="📡" display="block" />
56
- ```
57
-
58
- For plain HTML or other frameworks, import `@dofy/native-emoji-motion/element` once in a bundled entry module, then use `<emoji-motion emoji="👋" display="block"></emoji-motion>`. The web component can be used with Angular or any other framework that supports custom elements. It registers itself when the module is imported. Its attributes include `emoji`, `skin-tone`, `display`, `label` and `decoration="false"`; assign the `registry` property to use a custom registry.
59
-
60
- React, Vue and Svelte components accept `emoji`, `skinTone`, `display`, `decoration`, `label`, `events`, and a custom `registry`. For a custom interaction target, React accepts `triggerRef` and Vue/Svelte accept `trigger`. Components clean up their listeners when updated or unmounted. A global skin tone set on a registry is read when a component renders; to update an already mounted component, update its `skinTone` prop or cause a framework rerender.
61
-
62
- Components do not set a mouse cursor: the emoji follows its surrounding context. Set `cursor: pointer` on a button or other control when the emoji is part of an action.
63
-
64
- ## Low level API
65
-
66
- Import the package in an ESM application:
67
-
68
- ```js
69
- import { emojiMotion } from '@dofy/native-emoji-motion';
70
-
71
- // Keep the emoji in its own inline-block element. The card can be the trigger.
72
- const dispose = emojiMotion.bind(emojiElement, '👋', { trigger: card });
73
- // When the component is removed:
74
- dispose();
75
- ```
76
-
77
- Or play once manually: `emojiMotion.play(emojiElement, '❤️')`. `resolve(glyph)` returns the entry, group and selected motion, or `undefined` when the input cannot be recognized. `list()` returns canonical entries; `groups()` lists groups. `bind` listens to `pointerenter` and `focus` by default. Pass `events: ['click']` or any DOM event names to change this. Make the trigger keyboard accessible; a `<button>` is a good default. The element being animated should have `display: inline-block`, and should be dedicated to the emoji because Web Animations controls its transform.
78
-
79
- Motion is skipped when the user prefers reduced motion. `play` returns `null` for unknown emoji, reduced motion, or an element without Web Animations support; otherwise it returns the browser's `Animation`. A repeated play cancels the previous one on the same element. `ignoreReducedMotion` is an explicit opt out and should only be used with the user's consent.
80
-
81
- ## Automatic classification
82
-
83
- The default entry recognizes emoji using data generated from Unicode Emoji 18.0, including fully qualified, minimally qualified, and unqualified sequences. A known emoji is classified by its Unicode group and subgroup and receives a conservative reusable motion; existing handcrafted presets retain their special animations and optional decorations. Exact Unicode sequences, including flags and ZWJ sequences, are used rather than guessing category from a code point range. `resolve()` exposes `source`, `unicodeGroup` and `unicodeSubgroup` where available:
84
-
85
- ```js
86
- import { emojiMotion } from '@dofy/native-emoji-motion';
87
-
88
- emojiMotion.resolve('🐶'); // source: 'unicode', subgroup: 'animal-mammal', motion: 'bounce'
89
- emojiMotion.resolve('👩🏽‍🚀').glyph; // 👩🏽‍🚀
90
- emojiMotion.resolve('📡').source; // 'preset', including its signal decoration
91
- emojiMotion.resolve('hello'); // undefined
92
- ```
93
-
94
- The precedence is: **user emoji and aliases → user subgroup or group classification rule → user group default → handcrafted preset → generated Unicode classification → conservative fallback**. User emoji entries always take precedence over user classification rules. Within user classifications, subgroup rules take precedence over group rules. An explicitly supplied group default overrides handcrafted and automatic motions for that group; a more specific user emoji or classification rule can override it. User-defined motions replace built in motions with the same ID. User classification rules also override matching handcrafted presets.
95
-
96
- ```js
97
- import { createEmojiMotion } from '@dofy/native-emoji-motion';
98
-
99
- const custom = createEmojiMotion({
100
- classifications: {
101
- groups: { 'Animals & Nature': { group: 'nature', motion: 'float' } },
102
- subgroups: { 'animal-bird': { group: 'nature', motion: 'swing' } },
103
- fallback: { group: 'other', motion: 'bounce' },
104
- },
105
- emoji: { '🐶': { group: 'hearts', name: 'My dog', motion: 'pulse' } },
106
- });
107
- // 🐶 uses pulse, 🐦 uses swing, and other animals use float.
108
- ```
109
-
110
- A generated Unicode name and subgroup are used for uncustomized emoji. `list()` remains the small set of handcrafted and explicitly added entries; it is not a list of the entire Unicode dataset. Generated classifications do not automatically create SVG decorations. Unknown sequences newer than the pinned dataset receive a generic fallback only if the JavaScript engine recognizes them as emoji; otherwise they remain unrecognized. Detection does not guarantee that the system font can render the glyph. Standalone components and arbitrary text are not treated as emoji.
111
-
112
- The `defaults: false` setting disables both built in presets and automatic classification unless `autoDetect: true` is explicitly supplied; with no built in groups or motions, the caller must define the groups and motions used by its rules. Set `autoDetect: false` to disable automatic classification while keeping built in presets.
113
-
114
- ## Smaller preset only entry
115
-
116
- Use the lite entry to omit the Unicode classifier and its data. Unknown emoji do not automatically animate; user-defined entries and handcrafted presets still work. The matching framework entries also omit Unicode data:
117
-
118
- ```js
119
- import { createEmojiMotion } from '@dofy/native-emoji-motion/lite';
120
- import { EmojiMotion as ReactEmoji } from '@dofy/native-emoji-motion/react/lite';
121
- import { EmojiMotion as VueEmoji } from '@dofy/native-emoji-motion/vue/lite';
122
- import '@dofy/native-emoji-motion/element/lite';
123
- // Svelte: import EmojiMotion from '@dofy/native-emoji-motion/svelte/EmojiMotionLite.svelte';
124
- ```
125
-
126
- Import either the full or lite web component entry on a page, not both: both register the same `emoji-motion` tag and the first registration wins. To keep a framework bundle small, use its lite entry rather than passing a lite registry to a full component entry, which still imports the full classifier.
127
-
128
- Unicode data is generated with `npm run generate:unicode` from a version-pinned, checksum-verified official file. The data is covered by [UNICODE-LICENSE.txt](./UNICODE-LICENSE.txt).
129
-
130
- ## Skin tone
131
-
132
- A registry can specify a default and change it later. `resolve` returns the glyph to render; animation never changes your element's text automatically.
133
-
134
- ```js
135
- import { createEmojiMotion } from '@dofy/native-emoji-motion';
136
-
137
- const motion = createEmojiMotion({ defaultSkinTone: 'medium' });
138
- motion.resolve('👋').glyph; // 👋🏽
139
- motion.resolve('👋', { skinTone: 'dark' }).glyph; // 👋🏿
140
- motion.setDefaultSkinTone('light');
141
- motion.resolve('👋').glyph; // 👋🏻
142
- ```
143
-
144
- The available values are `default`, `light`, `medium-light`, `medium`, `medium-dark` and `dark`. Explicitly supplied skin tone variants retain their tone unless a per-call override is supplied. Automatic skin tones are applied only where an official single-tone variant is unambiguous; mixed-tone sequences stay unchanged rather than being rewritten. Emoji without a skin tone mapping stay unchanged. When adding custom emoji, supply `skinTones` as an explicit map from the tone names to complete Unicode sequences, including any joiners; the library does not guess modifier placement. If the default tone changes, update rendered text by resolving again.
145
-
146
- ## Extend
147
-
148
- An isolated registry combines built ins and your definitions; existing IDs can be replaced. Set `defaults: false` to use only your own definitions. Emoji variants are explicit rather than guessed, so skin tone and multi code point sequences remain under your control.
149
-
150
- ```js
151
- import { createEmojiMotion } from '@dofy/native-emoji-motion';
152
-
153
- const motion = createEmojiMotion({
154
- motions: {
155
- wiggle: {
156
- keyframes: [
157
- { transform: 'rotate(0deg)' },
158
- { transform: 'rotate(12deg)' },
159
- { transform: 'rotate(0deg)' },
160
- ],
161
- options: { duration: 600, easing: 'ease-out' },
162
- origin: '50% 80%',
163
- },
164
- },
165
- groups: { creatures: { label: 'Creatures', motion: 'wiggle' } },
166
- emoji: { '🐙': { name: 'Octopus', group: 'creatures' } },
167
- });
168
- motion.bind(emojiElement, '🐙', { trigger: card });
169
- ```
170
-
171
- Groups select a default motion; an emoji entry can override it with `motion`, and `variants` lists exact alternate sequences that inherit its settings. References and duplicate variants are validated when a registry is created. Definitions are ordinary JavaScript objects; do not mutate exported built in data. Create a new registry to change configuration. API types are included.
172
-
173
- ### Motion families
174
-
175
- Families apply a shared motion to explicitly reviewed members. Built in families cover balls, plants, drinks, flying animals and people in poses. They do not assume every emoji in a Unicode subgroup moves alike. `resolve()` reports `source: 'family'` and `familyId` for family matches; `list()` remains the handcrafted emoji list, while `families()` returns family definitions.
176
-
177
- Use the configurable spring and sway builders to create reusable motions. A family can adjust Web Animations timing with `animation`, and combine named SVG effects with `decorations`:
178
-
179
- ```js
180
- import { createEmojiMotion, createSpringMotion } from '@dofy/native-emoji-motion';
181
-
182
- const motion = createEmojiMotion({
183
- motions: { smallBounce: createSpringMotion({ height: 14, rebounds: 2, duration: 900 }) },
184
- families: {
185
- snacks: {
186
- label: 'Snacks', group: 'food', motion: 'smallBounce',
187
- members: ['🍪', '🍩'],
188
- animation: { duration: 800 },
189
- decorations: ['impact', 'sparkle'],
190
- },
191
- },
192
- });
193
- ```
194
-
195
- Pass `motion` as the component's `registry` property to use this configuration. `createSwayMotion({ angle, duration, origin })` provides a direction neutral sway. Effects remain optional and require a decoration container when using the low level API. Each effect keeps its own anchor unless the caller explicitly overrides it.
196
-
197
- Custom emoji definitions take priority, followed by custom Unicode classifications and custom families. Built in single emoji presets precede built in families; remaining emoji use automatic classification. A custom group default overrides built in presets and families, while an explicitly defined custom family keeps its chosen motion. A custom family with the same ID replaces the built in family. Unicode variants and unambiguous skin tones are resolved by the full entry; for the lite entry, supply explicit `aliases: { '🧎🏽': '🧎' }` mappings when a family needs variants. Mixed tone sequences are never guessed or rewritten.
198
-
199
- ## Optional decorative effects
200
-
201
- Emoji can also reference a decoration preset. The built in 📡 example adds a neutral signal halo around the whole glyph; 🔔 and 🎵 add notes, and ✨ adds sparkles. Decorations are opt in and render inside a container you provide. Make the container `position: relative` and size it to the glyph. Effects do not change the emoji itself.
202
-
203
- ```html
204
- <button id="antenna"><span class="emoji-box"><span id="dish">📡</span></span></button>
205
- <style>.emoji-box { position: relative; display: inline-block } #dish { display: inline-block }</style>
206
- ```
207
-
208
- ```js
209
- emojiMotion.bind(document.querySelector('#dish'), '📡', {
210
- trigger: document.querySelector('#antenna'),
211
- decoration: {
212
- container: document.querySelector('.emoji-box'),
213
- // Optional: adjust for the font actually used by your application.
214
- anchor: { x: '50%', y: '50%' },
215
- flipX: false,
216
- rotate: 0,
217
- },
218
- });
219
- ```
220
-
221
- `anchor` is relative to the container. `flipX` and `rotate` affect the decoration, never the glyph. For asymmetric effects, select explicit settings for the actual font or environment you support; OS detection alone cannot reliably determine the font or its direction. The default signal effect is centered rather than tied to a specific antenna position. When no decoration container is supplied, only the emoji moves. Decoration layers are removed after playback, on replay and on cleanup; reduced motion skips both.
222
-
223
- Add custom `decorations` in `createEmojiMotion`: each named preset has an `anchor` and `layers`, each with CSS `style`, Web Animations `keyframes` and `options`. Optionally provide `svg: { viewBox, elements }`, where each element has a `tag` and `attrs`; without `svg`, a styled span is used. Reference its name using the emoji entry's `decoration` field. Layer styles are trusted application code, and the library does not bundle images or fonts.
224
-
225
- For example, this adds an SVG sparkle to a custom group:
226
-
227
- ```js
228
- const custom = createEmojiMotion({
229
- decorations: {
230
- star: {
231
- anchor: { x: '50%', y: '50%' },
232
- layers: [{
233
- svg: { viewBox: '0 0 100 100', elements: [
234
- { tag: 'circle', attrs: { cx: '50', cy: '20', r: '5', fill: 'gold' } },
235
- ] },
236
- style: { width: '160%', height: '160%' },
237
- keyframes: [
238
- { opacity: 1, transform: 'translate(-50%, -50%) scale(.6)' },
239
- { opacity: 0, transform: 'translate(-50%, -50%) scale(1.2)' },
240
- ],
241
- options: { duration: 700 },
242
- }],
243
- },
244
- },
245
- emoji: { '🌟': { name: 'Glowing star', group: 'symbols', decoration: 'star' } },
246
- });
247
- custom.bind(emojiElement, '🌟', { trigger: card, decoration: { container: emojiBox } });
248
48
  ```
249
49
 
250
- ## Platform boundaries
50
+ Use components with React, Vue, Svelte or the Web Component, or add motion to existing text with the `enhance()` API. See the [documentation](https://emoji-motion.phpz.org/docs/) for setup, framework examples, skin tones, customization and API details.
251
51
 
252
- The library animates the entire native glyph, not individual parts. Rendering, supported emoji, sizes and font fallback vary by operating system, browser and installed fonts. Unsupported sequences may appear as missing glyphs or multiple characters. This library deliberately ships no emoji images or fonts and does not replace unsupported glyphs. Test the target browser, OS and font combinations for your product. Keyframes use relative transforms, but the visual pivot can still vary between fonts.
52
+ > Upgrading from 0.3.0? The `display` prop was removed in 0.4.0. Use a block container or CSS to control layout.
253
53
 
254
- ## Development and release
54
+ ## 📄 License
255
55
 
256
- `npm test` runs the registry and lifecycle tests; `npm run build:demo` creates the isolated static site in `dist/`; `npm run pack:check` previews package contents. The core uses ESM and the browser Web Animations API; it does not require a framework. See [RELEASE.md](https://github.com/dofy/emoji-motion/blob/main/RELEASE.md) for the release checklist.
56
+ [MIT](./LICENSE). Unicode data is covered by the [Unicode License](./UNICODE-LICENSE.txt).
@@ -0,0 +1,8 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" role="img" aria-label="Emoji Motion logo">
2
+ <circle cx="48" cy="48" r="46" fill="#302d29"/>
3
+ <circle cx="43" cy="49" r="23" fill="#efe6f6"/>
4
+ <circle cx="35" cy="44" r="2.6" fill="#302d29"/>
5
+ <circle cx="51" cy="44" r="2.6" fill="#302d29"/>
6
+ <path d="M34 55c3.2 4.2 6.3 6 9 6s5.8-1.8 9-6" fill="none" stroke="#302d29" stroke-width="3.5" stroke-linecap="round"/>
7
+ <path d="M71 31c3 2.4 5 5.4 6 9M73 48c0 3.2-.5 5.7-1.5 8" fill="none" stroke="#ef956d" stroke-width="4" stroke-linecap="round"/>
8
+ </svg>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dofy/native-emoji-motion",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Expressive, extensible animations for native emoji with optional SVG effects",
5
5
  "type": "module",
6
6
  "exports": {
@@ -60,6 +60,7 @@
60
60
  "types": "./src/index.d.ts",
61
61
  "files": [
62
62
  "src",
63
+ "assets/logo.svg",
63
64
  "README.md",
64
65
  "LICENSE",
65
66
  "UNICODE-LICENSE.txt",
@@ -5,7 +5,6 @@
5
5
  export let skinTone = undefined;
6
6
  export let registry = emojiMotion;
7
7
  export let decoration = true;
8
- export let display = 'inline';
9
8
  export let trigger = undefined;
10
9
  export let label = undefined;
11
10
  export let events = ['pointerenter', 'focus', 'click'];
@@ -13,8 +12,8 @@
13
12
  $: resolved = registry.resolve(emoji, { skinTone });
14
13
  </script>
15
14
 
16
- <span bind:this={wrapper} role="img" aria-label={label ?? resolved?.name ?? emoji}
17
- tabindex={trigger ? -1 : 0} style:display={display === 'block' ? 'block' : 'inline-block'}>
15
+ <span bind:this={wrapper} data-emoji-motion="component" role="img" aria-label={label ?? resolved?.name ?? emoji}
16
+ tabindex={trigger ? -1 : 0}>
18
17
  <span style="display: inline-block; position: relative">
19
18
  <span aria-hidden="true" style="display: inline-block"
20
19
  use:animateEmoji={{ emoji, skinTone, registry, trigger: trigger ?? wrapper, decoration, events }}>{resolved?.glyph ?? emoji}</span>
@@ -5,7 +5,6 @@ export interface EmojiMotionProps {
5
5
  skinTone?: SkinTone;
6
6
  registry?: Registry;
7
7
  decoration?: boolean;
8
- display?: 'inline' | 'block';
9
8
  trigger?: EventTarget;
10
9
  label?: string;
11
10
  events?: string[];
@@ -5,7 +5,6 @@
5
5
  export let skinTone = undefined;
6
6
  export let registry = emojiMotion;
7
7
  export let decoration = true;
8
- export let display = 'inline';
9
8
  export let trigger = undefined;
10
9
  export let label = undefined;
11
10
  export let events = ['pointerenter', 'focus', 'click'];
@@ -13,8 +12,8 @@
13
12
  $: resolved = registry.resolve(emoji, { skinTone });
14
13
  </script>
15
14
 
16
- <span bind:this={wrapper} role="img" aria-label={label ?? resolved?.name ?? emoji}
17
- tabindex={trigger ? -1 : 0} style:display={display === 'block' ? 'block' : 'inline-block'}>
15
+ <span bind:this={wrapper} data-emoji-motion="component" role="img" aria-label={label ?? resolved?.name ?? emoji}
16
+ tabindex={trigger ? -1 : 0}>
18
17
  <span style="display: inline-block; position: relative">
19
18
  <span aria-hidden="true" style="display: inline-block"
20
19
  use:animateEmoji={{ emoji, skinTone, registry, trigger: trigger ?? wrapper, decoration, events }}>{resolved?.glyph ?? emoji}</span>
@@ -2,7 +2,7 @@ const BaseElement = globalThis.HTMLElement ?? class {};
2
2
  /** Framework-independent web component. Import this module to register <emoji-motion>. */
3
3
  export function createEmojiMotionElement(emojiMotion) {
4
4
  return class EmojiMotionElement extends BaseElement {
5
- static observedAttributes = ['emoji', 'skin-tone', 'display', 'decoration', 'label'];
5
+ static observedAttributes = ['emoji', 'skin-tone', 'decoration', 'label'];
6
6
  #registry = emojiMotion;
7
7
  #dispose = () => {};
8
8
  get registry() { return this.#registry; }
@@ -15,7 +15,6 @@ export function createEmojiMotionElement(emojiMotion) {
15
15
  const emoji = this.getAttribute('emoji') ?? '';
16
16
  const skinTone = this.getAttribute('skin-tone') ?? undefined;
17
17
  const result = this.#registry.resolve(emoji, { skinTone });
18
- this.style.display = this.getAttribute('display') === 'block' ? 'block' : 'inline-block';
19
18
  this.setAttribute('role', 'img');
20
19
  this.setAttribute('aria-label', this.getAttribute('label') ?? result?.name ?? emoji);
21
20
  if (!this.hasAttribute('tabindex')) this.tabIndex = 0;
package/src/enhance.js ADDED
@@ -0,0 +1,95 @@
1
+ const excluded = 'script, style, noscript, template, textarea, input, select, option, pre, code, kbd, samp, svg, math, [contenteditable]:not([contenteditable="false"]), [data-emoji-motion], [data-emoji-motion-ignore], emoji-motion';
2
+ const fallbackPattern = /(?:[\u{1F1E6}-\u{1F1FF}]{2}|[#*0-9]\uFE0F?\u20E3|\p{Extended_Pictographic}[\uFE0E\uFE0F]?(?:\p{Emoji_Modifier})?(?:[\u{E0020}-\u{E007E}]+\u{E007F})?(?:\u200D\p{Extended_Pictographic}[\uFE0E\uFE0F]?(?:\p{Emoji_Modifier})?)*)/gu;
3
+
4
+ // Do not split a ZWJ sequence, flag, keycap, or skin tone into separate glyphs.
5
+ function segments(text, segmenter) {
6
+ if (segmenter) {
7
+ return Array.from(segmenter.segment(text), part => ({ glyph: part.segment, index: part.index }));
8
+ }
9
+ return Array.from(text.matchAll(fallbackPattern), match => ({ glyph: match[0], index: match.index }));
10
+ }
11
+
12
+ export function enhance(registry, options = {}) {
13
+ const doc = globalThis.document;
14
+ if (!doc) throw new Error('enhance() requires a browser document');
15
+ const root = typeof options.root === 'string' ? doc.querySelector(options.root) : (options.root ?? doc.body);
16
+ if (!root || ![1, 9, 11].includes(root.nodeType)) throw new Error('enhance() root must match an element or be a DOM container');
17
+ const exclusion = options.exclude ? `${excluded}, ${options.exclude}` : excluded;
18
+ // Validate selectors before doing any DOM work.
19
+ doc.createElement('span').matches(exclusion);
20
+ const segmenter = typeof Intl.Segmenter === 'function' ? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) : null;
21
+ const owned = new Map();
22
+ let closed = false;
23
+ let enabled = false;
24
+ const isExcluded = node => {
25
+ const parent = node.nodeType === 1 ? node : node.parentElement;
26
+ return Boolean(parent?.closest(exclusion));
27
+ };
28
+ function transform(node) {
29
+ if (!node.parentNode || isExcluded(node)) return;
30
+ const text = node.data;
31
+ const hits = segments(text, segmenter).filter(({ glyph }) => registry.resolve(glyph));
32
+ if (!hits.length) return;
33
+ const fragment = node.ownerDocument.createDocumentFragment();
34
+ let cursor = 0;
35
+ for (const { glyph, index } of hits) {
36
+ if (index > cursor) fragment.append(node.ownerDocument.createTextNode(text.slice(cursor, index)));
37
+ const wrapper = node.ownerDocument.createElement('span');
38
+ wrapper.setAttribute('data-emoji-motion', 'enhanced');
39
+ Object.assign(wrapper.style, { display: 'inline-block', position: 'relative' });
40
+ const inner = node.ownerDocument.createElement('span');
41
+ inner.style.display = 'inline-block';
42
+ inner.textContent = glyph;
43
+ wrapper.append(inner);
44
+ const dispose = registry.bind(inner, glyph, { events: options.events ?? ['pointerenter', 'focus', 'click'], ...(options.decoration ? { decoration: { container: wrapper } } : {}) });
45
+ owned.set(wrapper, dispose);
46
+ fragment.append(wrapper);
47
+ cursor = index + glyph.length;
48
+ }
49
+ if (cursor < text.length) fragment.append(node.ownerDocument.createTextNode(text.slice(cursor)));
50
+ node.replaceWith(fragment);
51
+ }
52
+ function scan(container) {
53
+ if (isExcluded(container)) return;
54
+ if (container.nodeType === 3) { transform(container); return; }
55
+ const walker = container.ownerDocument.createTreeWalker(container, 4);
56
+ const nodes = [];
57
+ while (walker.nextNode()) if (!isExcluded(walker.currentNode)) nodes.push(walker.currentNode);
58
+ for (const node of nodes) transform(node);
59
+ }
60
+ const Observer = root.ownerDocument?.defaultView?.MutationObserver ?? doc.defaultView?.MutationObserver;
61
+ const observer = options.observe === false || !Observer ? null : new Observer(records => {
62
+ if (!enabled || closed) return;
63
+ const candidates = new Set();
64
+ for (const record of records) {
65
+ if (record.type === 'characterData') candidates.add(record.target);
66
+ else for (const node of record.addedNodes) if ([1, 3, 11].includes(node.nodeType)) candidates.add(node);
67
+ }
68
+ for (const node of candidates) if (root.contains(node)) scan(node);
69
+ // Discard bindings for removed content without touching nodes the application moved.
70
+ for (const [wrapper, dispose] of owned) if (!root.contains(wrapper)) { dispose(); wrapper.removeAttribute('data-emoji-motion'); owned.delete(wrapper); }
71
+ });
72
+ function disable() {
73
+ if (closed || !enabled) return;
74
+ enabled = false;
75
+ observer?.disconnect();
76
+ for (const [wrapper, dispose] of owned) {
77
+ dispose();
78
+ if (wrapper.parentNode && root.contains(wrapper)) wrapper.replaceWith(wrapper.ownerDocument.createTextNode(wrapper.textContent));
79
+ }
80
+ owned.clear();
81
+ }
82
+ function enable() {
83
+ if (closed) throw new Error('Cannot enable a cleaned up enhancer');
84
+ if (enabled) return;
85
+ enabled = true;
86
+ scan(root.nodeType === 9 ? root.documentElement : root);
87
+ observer?.observe(root, { childList: true, subtree: true, characterData: true });
88
+ }
89
+ if (options.enabled !== false) enable();
90
+ return { enable, disable, cleanup() {
91
+ if (closed) return;
92
+ disable();
93
+ closed = true;
94
+ } };
95
+ }
package/src/index.d.ts CHANGED
@@ -12,7 +12,9 @@ export interface Config { defaults?: boolean; autoDetect?: boolean; classificati
12
12
  export interface PlayOptions { ignoreReducedMotion?: boolean; skinTone?: SkinTone; animation?: KeyframeAnimationOptions; decoration?: { container: HTMLElement; anchor?: Anchor; flipX?: boolean; rotate?: number } }
13
13
  export interface BindOptions extends PlayOptions { trigger?: EventTarget; events?: string[] }
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
- export interface EmojiMotion { resolve(glyph: string, options?: { skinTone?: SkinTone }): Resolution | undefined; setDefaultSkinTone(tone: SkinTone): void; getDefaultSkinTone(): SkinTone; play(element: Element, glyph: string, options?: PlayOptions): Animation | null; bind(element: Element, glyph: string, options?: BindOptions): () => void; list(): string[]; groups(): (Group & { id: string })[]; families(): (MotionFamily & { id: string })[] }
15
+ export interface EnhanceOptions { root?: string | Element | Document | DocumentFragment; exclude?: string; enabled?: boolean; observe?: boolean; decoration?: boolean; events?: string[] }
16
+ export interface EmojiEnhancer { enable(): void; disable(): void; cleanup(): void }
17
+ export interface EmojiMotion { enhance(options?: EnhanceOptions): EmojiEnhancer; resolve(glyph: string, options?: { skinTone?: SkinTone }): Resolution | undefined; setDefaultSkinTone(tone: SkinTone): void; getDefaultSkinTone(): SkinTone; play(element: Element, glyph: string, options?: PlayOptions): Animation | null; bind(element: Element, glyph: string, options?: BindOptions): () => void; list(): string[]; groups(): (Group & { id: string })[]; families(): (MotionFamily & { id: string })[] }
16
18
  export declare function createEmojiMotion(config?: Config): EmojiMotion;
17
19
  export declare const motions: Record<string, Motion>;
18
20
  export declare const groups: Record<string, Group>;
package/src/lite.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  export { createEmojiMotion, emojiMotion, motions, groups, emoji, decorations, families, createSpringMotion, createSwayMotion } from './index.js';
2
- export type { Motion, MotionFamily, Group, Anchor, SvgShape, DecorationLayer, Decoration, SkinTone, EmojiEntry, Config, ClassificationRule, PlayOptions, BindOptions, Resolution, EmojiMotion } from './index.js';
2
+ export type { Motion, MotionFamily, Group, Anchor, SvgShape, DecorationLayer, Decoration, SkinTone, EmojiEntry, Config, ClassificationRule, PlayOptions, BindOptions, EnhanceOptions, EmojiEnhancer, Resolution, EmojiMotion } from './index.js';
package/src/presets.js CHANGED
@@ -46,7 +46,17 @@ export const motions = {
46
46
  leafSway: createSwayMotion(),
47
47
  hoverFlight: { keyframes: [{ transform: 'translateY(0) rotate(0deg)' }, { transform: 'translateY(-15%) rotate(-6deg)', offset: .3 }, { transform: 'translateY(-9%) rotate(6deg)', offset: .6 }, { transform: 'translateY(0) rotate(0deg)' }], options: { duration: 1000, easing: 'cubic-bezier(.22,1,.36,1)' } },
48
48
  gentleSip: { keyframes: [{ transform: 'translateY(0) rotate(0deg)' }, { transform: 'translateY(-7%) rotate(-4deg)', offset: .35 }, { transform: 'translateY(-7%) rotate(4deg)', offset: .65 }, { transform: 'translateY(0) rotate(0deg)' }], options: { duration: 1000, easing: 'cubic-bezier(.22,1,.36,1)' }, origin: '50% 85%' },
49
- pose: { keyframes: [{ transform: 'scale(1) translateY(0)' }, { transform: 'scale(1.04,1.06) translateY(-3%)', offset: .45 }, { transform: 'scale(1) translateY(0)' }], options: { duration: 1100, easing: 'cubic-bezier(.22,1,.36,1)' }, origin: '50% 90%' }
49
+ pose: { keyframes: [{ transform: 'scale(1) translateY(0)' }, { transform: 'scale(1.04,1.06) translateY(-3%)', offset: .45 }, { transform: 'scale(1) translateY(0)' }], options: { duration: 1100, easing: 'cubic-bezier(.22,1,.36,1)' }, origin: '50% 90%' },
50
+ carousel: { keyframes: [{ transform: 'perspective(500px) rotateY(0deg) translateY(0)' }, { transform: 'perspective(500px) rotateY(-35deg) translateY(-8%)', offset: .3 }, { transform: 'perspective(500px) rotateY(35deg) translateY(-4%)', offset: .65 }, { transform: 'perspective(500px) rotateY(0deg) translateY(0)' }], options: { duration: 1200, easing: 'cubic-bezier(.22,1,.36,1)' }, origin: '50% 85%' },
51
+ flipHorizontal: { keyframes: [{ transform: 'perspective(500px) rotateY(0deg)' }, { transform: 'perspective(500px) rotateY(180deg)', offset: .5 }, { transform: 'perspective(500px) rotateY(360deg)' }], options: { duration: 1000, easing: 'cubic-bezier(.22,1,.36,1)' } },
52
+ flipVertical: { keyframes: [{ transform: 'perspective(500px) rotateX(0deg)' }, { transform: 'perspective(500px) rotateX(180deg)', offset: .5 }, { transform: 'perspective(500px) rotateX(360deg)' }], options: { duration: 1000, easing: 'cubic-bezier(.22,1,.36,1)' } },
53
+ steady: { keyframes: [{ transform: 'scale(1)' }, { transform: 'scale(1.025)', offset: .5 }, { transform: 'scale(1)' }], options: { duration: 1100, easing: 'cubic-bezier(.22,1,.36,1)' }, origin: '50% 100%' },
54
+ hourglassTurn: { keyframes: [{ transform: 'rotate(0deg)' }, { transform: 'rotate(180deg)', offset: .4 }, { transform: 'rotate(180deg)', offset: .6 }, { transform: 'rotate(0deg)' }], options: { duration: 1300, easing: 'cubic-bezier(.22,1,.36,1)' } },
55
+ underwater: { keyframes: [{ transform: 'translateY(0) rotate(0deg)' }, { transform: 'translateY(-9%) rotate(-4deg)', offset: .35 }, { transform: 'translateY(3%) rotate(4deg)', offset: .7 }, { transform: 'translateY(0) rotate(0deg)' }], options: { duration: 1200, easing: curve } },
56
+ pastryBounce: createSpringMotion({ height: 12, rebounds: 1, duration: 850 }),
57
+ railRide: { keyframes: [{ transform: 'translateY(0) rotate(0deg)' }, { transform: 'translateY(-3%) rotate(-2deg)', offset: .25 }, { transform: 'translateY(0) rotate(2deg)', offset: .55 }, { transform: 'translateY(-2%) rotate(-1deg)', offset: .75 }, { transform: 'translateY(0) rotate(0deg)' }], options: { duration: 1000, easing: curve }, origin: '50% 85%' },
58
+ rhythm: { keyframes: [{ transform: 'rotate(0deg)' }, { transform: 'rotate(-5deg)' }, { transform: 'rotate(5deg)' }, { transform: 'rotate(-3deg)' }, { transform: 'rotate(0deg)' }], options: { duration: 1050, easing: curve }, origin: '50% 80%' },
59
+ 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%' }
50
60
  };
51
61
 
52
62
  // Groups describe semantic families; emoji entries may override the group's motion.
@@ -70,13 +80,29 @@ const skinTones = glyph => Object.fromEntries(['light', 'medium-light', 'medium'
70
80
  // A person modifier belongs before the joiner, never at the end of the sequence.
71
81
  const personSkinTones = suffix => Object.fromEntries(['light', 'medium-light', 'medium', 'medium-dark', 'dark'].map((tone, index) => [tone, `🧑${String.fromCodePoint(0x1f3fb + index)}‍${suffix}`]));
72
82
  const familyToneAliases = glyphs => Object.fromEntries(glyphs.flatMap(glyph => Object.values(skinTones(glyph)).map(variant => [variant, glyph])));
83
+ // These reviewed ZWJ families use explicit suffixes; tests check every sequence against Unicode.
84
+ const rolePeople = ['🧑', '👨', '👩'];
85
+ const roleSuffixes = ['⚕️', '🎓', '🏫', '🌾', '🔧', '🔬', '🚒'];
86
+ const roleMembers = roleSuffixes.flatMap(suffix => rolePeople.map(person => `${person}‍${suffix}`));
87
+ const roleAliases = Object.fromEntries(roleSuffixes.flatMap(suffix => rolePeople.flatMap(person => Object.values(skinTones(person)).map(variant => [`${variant}‍${suffix}`, `${person}‍${suffix}`]))));
88
+ const walkerMembers = ['🚶', '🚶‍♂️', '🚶‍♀️', '🏃‍♂️', '🏃‍♀️'];
89
+ const walkerAliases = Object.fromEntries(walkerMembers.flatMap(glyph => {
90
+ const [person, suffix = ''] = glyph.split('‍');
91
+ return Object.values(skinTones(person)).map(variant => [suffix ? `${variant}‍${suffix}` : variant, glyph]);
92
+ }));
73
93
  // Members are reviewed explicitly; no entire Unicode subgroup is assumed to move alike.
74
94
  export const families = {
75
95
  balls: { label: 'Balls', group: 'activities', motion: 'spring', members: ['⚾', '🥎', '🎱', '🎳'], animation: { duration: 900 }, decorations: ['impact', 'sparkle'] },
76
96
  plants: { label: 'Plants', group: 'nature', motion: 'leafSway', members: ['🌷', '🌹', '🌺', '🪻', '🌴', '🌿', '🍀'] },
77
97
  drinks: { label: 'Drinks', group: 'food', motion: 'gentleSip', members: ['🥤', '🧃', '🧋', '🍹', '🍸'] },
78
98
  flying: { label: 'Flying animals', group: 'nature', motion: 'hoverFlight', members: ['🦜', '🦉', '🦅', '🪰', '🦟'] },
79
- poses: { label: 'People in poses', group: 'people', motion: 'pose', members: ['🧎', '🧍', '🙇'], aliases: familyToneAliases(['🧎', '🧍', '🙇']) }
99
+ poses: { label: 'People in poses', group: 'people', motion: 'pose', members: ['🧎', '🧍', '🙇'], aliases: familyToneAliases(['🧎', '🧍', '🙇']) },
100
+ professionals: { label: 'Professionals', group: 'people', motion: 'pose', members: roleMembers, aliases: roleAliases },
101
+ walkers: { label: 'Walking and running', group: 'people', motion: 'steps', members: walkerMembers, aliases: walkerAliases },
102
+ seaLife: { label: 'Sea life', group: 'nature', motion: 'underwater', members: ['🐠', '🐡', '🦈', '🐋', '🐳', '🦭'], decoration: 'bubbles' },
103
+ bakery: { label: 'Bakery', group: 'food', motion: 'pastryBounce', members: ['🍞', '🥐', '🥖', '🥨', '🥯', '🥞', '🧇'] },
104
+ rail: { label: 'Rail vehicles', group: 'travel', motion: 'railRide', members: ['🚄', '🚅', '🚆', '🚇', '🚈', '🚊', '🚝', '🚞'] },
105
+ instruments: { label: 'Instruments', group: 'activities', motion: 'rhythm', members: ['🎹', '🎷', '🎺', '🪕', '🪗', '🎻', '🪈'], decoration: 'sound' }
80
106
  };
81
107
  // Variants are explicit: unrelated sequences are never silently normalized.
82
108
  export const emoji = {
@@ -103,9 +129,9 @@ export const emoji = {
103
129
  '❤️': { group: 'hearts', name: 'Red heart', variants: ['❤'] }, '💛': { group: 'hearts', name: 'Yellow heart' }, '💖': { group: 'hearts', name: 'Sparkling heart' },
104
130
  '💚': { group: 'hearts', name: 'Green heart' }, '💙': { group: 'hearts', name: 'Blue heart' }, '💜': { group: 'hearts', name: 'Purple heart' }, '💔': { group: 'hearts', name: 'Broken heart', motion: 'shake' },
105
131
  '🔍': { group: 'objects', name: 'Magnifying glass', motion: 'search' }, '🔔': { group: 'objects', name: 'Bell', motion: 'swing', decoration: 'sound' }, '🎁': { group: 'objects', name: 'Gift', motion: 'shake' }, '📡': { group: 'objects', name: 'Satellite antenna', motion: 'wobble', decoration: 'signal' }, '⏱️': { group: 'objects', name: 'Stopwatch', motion: 'swing', variants: ['⏱'] },
106
- '💡': { group: 'objects', name: 'Light bulb', motion: 'pulse' }, '📷': { group: 'objects', name: 'Camera', motion: 'bounce' }, '🔑': { group: 'objects', name: 'Key', motion: 'wobble' }, '📌': { group: 'objects', name: 'Pushpin', motion: 'bounce' }, '📦': { group: 'objects', name: 'Package', motion: 'shake' }, '🧭': { group: 'objects', name: 'Compass', motion: 'spin' },
107
- '🌸': { group: 'nature', name: 'Blossom', decoration: 'petals' }, '🍃': { group: 'nature', name: 'Leaves' }, '☀️': { group: 'nature', name: 'Sun', motion: 'spin', variants: ['☀'] },
108
- '🌻': { group: 'nature', name: 'Sunflower', motion: 'swing' }, '🌈': { group: 'nature', name: 'Rainbow', motion: 'float' }, '🌙': { group: 'nature', name: 'Crescent moon', motion: 'float' }, '⭐': { group: 'nature', name: 'Star', motion: 'pulse', decoration: 'sparkle' }, '🔥': { group: 'nature', name: 'Fire', motion: 'wobble' }, '🌊': { group: 'nature', name: 'Water wave', motion: 'float' },
132
+ '💡': { 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' },
133
+ '🌸': { group: 'nature', name: 'Blossom', decoration: 'petals' }, '🍃': { group: 'nature', name: 'Leaves' }, '☀️': { group: 'nature', name: 'Sun', motion: 'spin', decoration: 'glow', variants: ['☀'] },
134
+ '🌻': { 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' },
109
135
  '🚀': { group: 'travel', name: 'Rocket' }, '✈️': { group: 'travel', name: 'Airplane', motion: 'glide', variants: ['✈'] }, '🚲': { group: 'travel', name: 'Bicycle', motion: 'ride' },
110
136
  '🚗': { group: 'travel', name: 'Car', motion: 'ride' }, '🚂': { group: 'travel', name: 'Locomotive', motion: 'ride' }, '🛸': { group: 'travel', name: 'Flying saucer', motion: 'float' }, '🚢': { group: 'travel', name: 'Ship', motion: 'wobble' },
111
137
  '✨': { 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' },
@@ -113,7 +139,7 @@ export const emoji = {
113
139
  '☕': { group: 'food', name: 'Hot beverage', motion: 'sip', decoration: 'steam' }, '🍵': { group: 'food', name: 'Teacup', motion: 'sip', decoration: 'steam' }, '🍕': { group: 'food', name: 'Pizza', motion: 'bounce' }, '🍦': { group: 'food', name: 'Soft ice cream', motion: 'sway' },
114
140
  '🏀': { group: 'activities', name: 'Basketball', motion: 'hop' }, '⚽': { group: 'activities', name: 'Soccer ball', motion: 'hop' }, '🎈': { group: 'activities', name: 'Balloon', motion: 'float' }, '🎉': { group: 'activities', name: 'Party popper', motion: 'bounce', decoration: 'sparkle' },
115
141
  '💃': { group: 'people', name: 'Woman dancing', motion: 'twirl', skinTones: skinTones('💃') }, '🕺': { group: 'people', name: 'Man dancing', motion: 'twirl', skinTones: skinTones('🕺') },
116
- '⏳': { group: 'objects', name: 'Hourglass', motion: 'flip' }, '⏰': { group: 'objects', name: 'Alarm clock', motion: 'shake' }, '🕯️': { group: 'objects', name: 'Candle', motion: 'sway', variants: ['🕯'] },
142
+ '⏳': { group: 'objects', name: 'Hourglass', motion: 'hourglassTurn' }, '⏰': { group: 'objects', name: 'Alarm clock', motion: 'shake' }, '🕯️': { group: 'objects', name: 'Candle', motion: 'steady', decoration: 'flicker', variants: ['🕯'] },
117
143
 
118
144
  // Additional handcrafted entries share motions where their physical action fits.
119
145
  '😀': { group: 'faces', name: 'Grinning face', motion: 'bounce' }, '🤣': { group: 'faces', name: 'Rolling on the floor laughing', motion: 'shake' },
@@ -143,9 +169,26 @@ export const emoji = {
143
169
  '🧑‍🚀': { group: 'people', name: 'Astronaut', motion: 'float', skinTones: personSkinTones('🚀') }, '🧑‍💻': { group: 'people', name: 'Technologist', motion: 'nod', skinTones: personSkinTones('💻') },
144
170
  '📱': { group: 'objects', name: 'Mobile phone', motion: 'shake' }, '📚': { group: 'objects', name: 'Books', motion: 'nod' }, '✉️': { group: 'objects', name: 'Envelope', motion: 'bob', variants: ['✉'] },
145
171
  '💌': { group: 'objects', name: 'Love letter', motion: 'bob', decoration: 'sparkle' }, '🔒': { group: 'objects', name: 'Locked', motion: 'wobble' }, '🔓': { group: 'objects', name: 'Unlocked', motion: 'pop' },
146
- '🔦': { group: 'objects', name: 'Flashlight', motion: 'nod' }, '🪄': { group: 'objects', name: 'Magic wand', motion: 'twirl', decoration: 'sparkle' }, '🎤': { group: 'objects', name: 'Microphone', motion: 'swing', decoration: 'sound' },
172
+ '🔦': { 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' },
147
173
  '🚌': { group: 'travel', name: 'Bus', motion: 'ride' }, '🚕': { group: 'travel', name: 'Taxi', motion: 'ride' }, '🚁': { group: 'travel', name: 'Helicopter', motion: 'bob' },
148
- '🎡': { group: 'travel', name: 'Ferris wheel', motion: 'spin' }, '⛵': { group: 'travel', name: 'Sailboat', motion: 'bob' }, '🎠': { group: 'travel', name: 'Carousel horse', motion: 'hop' }
174
+ '🎡': { group: 'travel', name: 'Ferris wheel', motion: 'steady' }, '⛵': { group: 'travel', name: 'Sailboat', motion: 'bob' }, '🎠': { group: 'travel', name: 'Carousel horse', motion: 'carousel' },
175
+ // Direction-neutral lighting for other objects that naturally emit or reflect light.
176
+ '📸': { group: 'objects', name: 'Camera with flash', motion: 'bounce', decoration: 'flash' },
177
+ '🪔': { group: 'objects', name: 'Oil lamp', motion: 'sway', decoration: 'flicker' },
178
+ '🚨': { group: 'objects', name: 'Police car light', motion: 'pulse', decoration: 'alertLight' },
179
+ '🔅': { group: 'symbols', name: 'Dim button', motion: 'breathe', decoration: 'glow' },
180
+ '🔆': { group: 'symbols', name: 'Bright button', motion: 'pulse', decoration: 'glow' },
181
+ '🌞': { group: 'nature', name: 'Sun with face', motion: 'breathe', decoration: 'glow' },
182
+ '🌟': { group: 'nature', name: 'Glowing star', motion: 'pulse', decoration: 'sparkle' },
183
+ '⚡': { group: 'nature', name: 'High voltage', motion: 'pop', decoration: 'flash' },
184
+ '🎆': { group: 'activities', name: 'Fireworks', motion: 'pop', decoration: 'sparkle' },
185
+ '🎇': { group: 'activities', name: 'Sparkler', motion: 'wobble', decoration: 'sparkle' },
186
+ '🔮': { group: 'objects', name: 'Crystal ball', motion: 'breathe', decoration: 'sparkle' },
187
+ '💎': { group: 'objects', name: 'Gem stone', motion: 'pulse', decoration: 'sparkle' },
188
+ '🪩': { group: 'objects', name: 'Mirror ball', motion: 'spin', decoration: 'sparkle' },
189
+ '🌠': { group: 'nature', name: 'Shooting star', motion: 'float', decoration: 'sparkle' },
190
+ '🪙': { group: 'objects', name: 'Coin', motion: 'flipHorizontal' },
191
+ '🎲': { group: 'activities', name: 'Game die', motion: 'spin' }
149
192
  };
150
193
 
151
194
  // Declarative SVG layers never alter the native glyph. Anchors are relative to
@@ -166,7 +209,32 @@ const steamShape = { viewBox: '0 0 100 100', elements: [
166
209
  const rainShape = { viewBox: '0 0 100 100', elements: [
167
210
  { tag: 'path', attrs: { d: 'M30 67v6M50 72v6M70 67v6', fill: 'none', stroke: '#7c9fca', 'stroke-width': '3', 'stroke-linecap': 'round' } }
168
211
  ] };
212
+ // Symmetric rays sit around the glyph rather than assuming a bulb, lens or flame position.
213
+ const lightShape = color => ({ viewBox: '0 0 100 100', elements: [
214
+ { tag: 'path', attrs: { d: 'M50 4v9M50 87v9M4 50h9M87 50h9M17 17l6 6M77 77l6 6M83 17l-6 6M23 77l-6 6', fill: 'none', stroke: color, 'stroke-width': '3', 'stroke-linecap': 'round' } }
215
+ ] });
169
216
  export const decorations = {
217
+ bubbles: { anchor: { x: '50%', y: '50%' }, layers: [{ svg: { viewBox: '0 0 100 100', elements: [
218
+ { tag: 'circle', attrs: { cx: '21', cy: '46', r: '3', fill: 'none', stroke: '#7cafd0', 'stroke-width': '2' } },
219
+ { tag: 'circle', attrs: { cx: '76', cy: '38', r: '4', fill: 'none', stroke: '#7cafd0', 'stroke-width': '2' } },
220
+ { tag: 'circle', attrs: { cx: '80', cy: '60', r: '2', fill: 'none', stroke: '#7cafd0', 'stroke-width': '2' } }
221
+ ] }, 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 } }] },
222
+ alertLight: { anchor: { x: '50%', y: '50%' }, layers: [{ svg: lightShape('#df625e'), style: centered,
223
+ 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)' }],
224
+ options: { duration: 900, easing: 'cubic-bezier(.22,1,.36,1)' }
225
+ }] },
226
+ glow: { anchor: { x: '50%', y: '50%' }, layers: [{ svg: lightShape('#e0a63e'), style: centered,
227
+ 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)' }],
228
+ options: { duration: 850, easing: 'cubic-bezier(.22,1,.36,1)' }
229
+ }] },
230
+ flicker: { anchor: { x: '50%', y: '50%' }, layers: [{ svg: lightShape('#df985c'), style: centered,
231
+ 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)' }],
232
+ options: { duration: 1100, easing: 'cubic-bezier(.22,1,.36,1)' }
233
+ }] },
234
+ flash: { anchor: { x: '50%', y: '50%' }, layers: [{ svg: lightShape('#a5b7df'), style: centered,
235
+ 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)' }],
236
+ options: { duration: 800, easing: 'cubic-bezier(.22,1,.36,1)' }
237
+ }] },
170
238
  impact: { anchor: { x: '50%', y: '50%' }, layers: [{ svg: { viewBox: '0 0 100 100', elements: [{ tag: 'path', attrs: { d: 'M28 75l-5 4M72 75l5 4M50 79v6', fill: 'none', stroke: '#d49b70', 'stroke-width': '3', 'stroke-linecap': 'round' } }] }, style: centered,
171
239
  keyframes: [{ opacity: 0, transform: 'translate(-50%, -50%) scale(.8)' }, { opacity: 0, transform: 'translate(-50%, -50%) scale(.8)', offset: .45 }, { opacity: .85, transform: 'translate(-50%, -50%) scale(1)', offset: .65 }, { opacity: 0, transform: 'translate(-50%, -50%) scale(1.1)' }], options: { duration: 900, easing: 'cubic-bezier(.22,1,.36,1)' }
172
240
  }] },
package/src/react-core.js CHANGED
@@ -4,7 +4,7 @@ import { createElement, useEffect, useRef } from 'react';
4
4
  const defaultEvents = ['pointerenter', 'focus', 'click'];
5
5
  /** A native emoji with optional motion and decorations. */
6
6
  export function createEmojiMotionComponent(emojiMotion) {
7
- return function EmojiMotion({ emoji, skinTone, registry = emojiMotion, decoration = true, events = defaultEvents, triggerRef, display = 'inline', label, className, style, ...attributes }) {
7
+ return function EmojiMotion({ emoji, skinTone, registry = emojiMotion, decoration = true, events = defaultEvents, triggerRef, label, className, style, ...attributes }) {
8
8
  const wrapper = useRef(null);
9
9
  const glyph = useRef(null);
10
10
  const visual = useRef(null);
@@ -13,7 +13,7 @@ export function createEmojiMotionComponent(emojiMotion) {
13
13
  if (!wrapper.current || !glyph.current) return;
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
- return createElement('span', { ...attributes, className, ref: wrapper, role: 'img', 'aria-label': label ?? result?.name ?? emoji, tabIndex: attributes.tabIndex ?? (triggerRef ? -1 : 0), style: { ...style, display: display === 'block' ? 'block' : 'inline-block' } },
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
17
  createElement('span', { ref: visual, style: { display: 'inline-block', position: 'relative' } },
18
18
  createElement('span', { ref: glyph, 'aria-hidden': 'true', style: { display: 'inline-block' } }, result?.glyph ?? emoji)));
19
19
  }
package/src/react.d.ts CHANGED
@@ -5,7 +5,6 @@ export interface EmojiMotionProps extends Omit<HTMLAttributes<HTMLSpanElement>,
5
5
  skinTone?: SkinTone;
6
6
  registry?: Registry;
7
7
  decoration?: boolean;
8
- display?: 'inline' | 'block';
9
8
  events?: string[];
10
9
  label?: string;
11
10
  triggerRef?: RefObject<HTMLElement | null>;
package/src/registry.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { enhance } from './enhance.js';
1
2
  import { motions as defaultMotions, groups as defaultGroups, emoji as defaultEmoji, decorations as defaultDecorations, families as defaultFamilies } from './presets.js';
2
3
 
3
4
  const toneNames = ['default', 'light', 'medium-light', 'medium', 'medium-dark', 'dark'];
@@ -179,5 +180,5 @@ export function createRegistry(config = {}, classifier) {
179
180
  for (const event of events) trigger.addEventListener(event, handler);
180
181
  return () => { for (const event of events) trigger.removeEventListener(event, handler); active.get(element)?.dispose(); active.delete(element); };
181
182
  }
182
- return { resolve, play, bind, setDefaultSkinTone(tone) { validateTone(tone); defaultSkinTone = tone; }, getDefaultSkinTone: () => defaultSkinTone, list: () => Object.keys(entries), groups: () => Object.entries(groups).map(([id, group]) => ({ id, ...group })), families: () => Object.entries({ ...builtinFamilies, ...userFamilies }).map(([id, family]) => ({ ...family, id, members: [...family.members], ...(family.aliases ? { aliases: { ...family.aliases } } : {}), ...(family.decorations ? { decorations: [...family.decorations] } : {}), ...(family.animation ? { animation: { ...family.animation } } : {}) })) };
183
+ return { resolve, play, bind, enhance(options) { return enhance({ resolve, bind }, options); }, setDefaultSkinTone(tone) { validateTone(tone); defaultSkinTone = tone; }, getDefaultSkinTone: () => defaultSkinTone, list: () => Object.keys(entries), groups: () => Object.entries(groups).map(([id, group]) => ({ id, ...group })), families: () => Object.entries({ ...builtinFamilies, ...userFamilies }).map(([id, family]) => ({ ...family, id, members: [...family.members], ...(family.aliases ? { aliases: { ...family.aliases } } : {}), ...(family.decorations ? { decorations: [...family.decorations] } : {}), ...(family.animation ? { animation: { ...family.animation } } : {}) })) };
183
184
  }
@@ -2,6 +2,8 @@
2
2
  /** Svelte action. Apply it to a span dedicated to the emoji. */
3
3
  export function createAnimateEmoji(emojiMotion) {
4
4
  return function animateEmoji(node, initial) {
5
+ const hadMarker = node.hasAttribute('data-emoji-motion');
6
+ if (!hadMarker) node.setAttribute('data-emoji-motion', 'action');
5
7
  let dispose = () => {};
6
8
  function update(options) {
7
9
  dispose();
@@ -10,6 +12,6 @@ export function createAnimateEmoji(emojiMotion) {
10
12
  dispose = registry.bind(node, emoji, { trigger, events, skinTone, ...(decoration && node.parentElement ? { decoration: { container: node.parentElement } } : {}) });
11
13
  }
12
14
  update(initial);
13
- return { update, destroy() { dispose(); } };
15
+ return { update, destroy() { dispose(); if (!hadMarker) node.removeAttribute('data-emoji-motion'); } };
14
16
  }
15
17
  }
package/src/vue-core.js CHANGED
@@ -7,7 +7,7 @@ export function createEmojiMotionComponent(emojiMotion) {
7
7
  inheritAttrs: false,
8
8
  props: {
9
9
  emoji: { type: String, required: true }, skinTone: String, registry: { type: Object, default: () => emojiMotion },
10
- decoration: { type: Boolean, default: true }, display: { type: String, default: 'inline' }, trigger: { type: Object, default: null }, events: { type: Array, default: () => defaultEvents }, label: String
10
+ decoration: { type: Boolean, default: true }, trigger: { type: Object, default: null }, events: { type: Array, default: () => defaultEvents }, label: String
11
11
  },
12
12
  setup(props, { attrs }) {
13
13
  const wrapper = ref(null); const visual = ref(null); const glyph = ref(null);
@@ -21,7 +21,7 @@ export function createEmojiMotionComponent(emojiMotion) {
21
21
  onBeforeUnmount(stop);
22
22
  return () => {
23
23
  const result = props.registry.resolve(props.emoji, { skinTone: props.skinTone });
24
- return h('span', { ...attrs, ref: wrapper, role: 'img', 'aria-label': props.label ?? result?.name ?? props.emoji, tabindex: attrs.tabindex ?? (props.trigger ? -1 : 0), style: [attrs.style, { display: props.display === 'block' ? 'block' : 'inline-block' }] },
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
25
  h('span', { ref: visual, style: { display: 'inline-block', position: 'relative' } },
26
26
  h('span', { ref: glyph, 'aria-hidden': 'true', style: { display: 'inline-block' } }, result?.glyph ?? props.emoji)));
27
27
  };
package/src/vue.d.ts CHANGED
@@ -5,7 +5,6 @@ export interface EmojiMotionProps {
5
5
  skinTone?: SkinTone;
6
6
  registry?: Registry;
7
7
  decoration?: boolean;
8
- display?: 'inline' | 'block';
9
8
  events?: string[];
10
9
  label?: string;
11
10
  trigger?: HTMLElement | null;