@dofy/native-emoji-motion 0.3.0 → 0.5.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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 — 2026-11-13
4
+
5
+ - Add reviewed motion families for fruit, animal faces, clouds, celestial bodies and writing tools.
6
+ - Add judges, pilots and singers with their official skin tone variants.
7
+ - Add five reusable motions and optional sparkles for fruit.
8
+
9
+ ## 0.4.0 — 2026-11-13
10
+
11
+ - **Breaking:** Remove the component `display` prop and web component attribute. Components use normal inline layout; use a block container or CSS for placement.
12
+ - Add `enhance()` to animate emoji in existing text and observe dynamic content.
13
+ - Expand reviewed motion families and improve the demo.
14
+
3
15
  ## 0.3.0 — 2026-11-13
4
16
 
5
17
  - Add lighting and sparkle effects for light sources, reflective objects, and celebrations.
package/README.ja.md ADDED
@@ -0,0 +1,44 @@
1
+ <h1 align="center">👋</h1>
2
+
3
+ <h1 align="center">Emoji Motion</h1>
4
+
5
+ <p align="center"><a href="./README.md">English</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a></p>
6
+
7
+ <p align="center"><strong>いつもの絵文字に、もう少し気持ちを。 ✨</strong></p>
8
+
9
+ <p align="center"><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> <a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license" /></a> <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></p>
10
+
11
+ 端末のネイティブ絵文字に小さく表情豊かなアニメーションを追加します。React、Vue、Svelte、Web Component に対応し、肌の色、任意の SVG エフェクト、カスタムモーション、Unicode による自動分類を利用できます。[ライブデモ](https://emoji-motion.phpz.org/)でお試しください。
12
+
13
+ ## 📊 コレクションの概要
14
+
15
+ | バージョン 0.5.0 | 数 | 内容 |
16
+ | --- | ---: | --- |
17
+ | 個別のプリセット | 177 | 個別に設定された基本絵文字 |
18
+ | 確認済みモーションファミリー | 16 | さらに 123 種類の基本絵文字に対応 |
19
+ | カテゴリ | 10 | コレクションの分類 |
20
+ | 再利用可能なモーション | 42 | プリセットとファミリーで共有 |
21
+ | SVG エフェクト付き絵文字 | 67 | 基本絵文字の数 |
22
+ | 認識できる Unicode シーケンス | 5,235 | 肌の色などのバリエーションを含む Unicode Emoji 18.0 |
23
+
24
+ 肌の色などのバリエーションは追加の Unicode シーケンスとして数えます。プリセットや確認済みファミリーに一致しない絵文字には、分類に基づくフォールバックを適用します。
25
+
26
+ ## 📦 はじめに
27
+
28
+ ```sh
29
+ npm install @dofy/native-emoji-motion
30
+ ```
31
+
32
+ ```jsx
33
+ import { EmojiMotion } from '@dofy/native-emoji-motion/react';
34
+
35
+ <p>Hello <EmojiMotion emoji="👋" skinTone="medium" /></p>
36
+ ```
37
+
38
+ React、Vue、Svelte コンポーネントまたは Web Component を使用できます。`enhance()` で既存のテキストにもモーションを追加できます。設定、パラメーター、肌の色、カスタマイズの詳細は[ドキュメント](https://emoji-motion.phpz.org/docs/)をご覧ください。
39
+
40
+ > 0.3.0 からアップグレードする場合:0.4.0 では `display` プロパティを削除しました。ブロック要素または CSS でレイアウトを調整してください。
41
+
42
+ ## 📄 ライセンス
43
+
44
+ [MIT](./LICENSE)。Unicode データには [Unicode License](./UNICODE-LICENSE.txt) が適用されます。
package/README.md CHANGED
@@ -1,256 +1,56 @@
1
- # Emoji Motion
1
+ <h1 align="center">👋</h1>
2
2
 
3
- Small, expressive animations for native emoji. A framework independent library with Unicode based automatic classification, reusable motion presets and explicit per emoji overrides.
3
+ <h1 align="center">Emoji Motion</h1>
4
4
 
5
- ## Collection at a glance
5
+ <p align="center"><a href="./README.md">English</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a></p>
6
6
 
7
- The current repository and demo contain **177 handcrafted emoji presets** and **5 reviewed motion families** with **24 additional base emoji**, across **10 categories**. They use **32 reusable motions**; **44 base emoji** have optional SVG effects. Skin tones and other variants are additional sequences, and similar emoji may share the same motion.
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>
8
11
 
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.3.0.
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>
10
17
 
11
- ## Try the demo
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/).
12
19
 
13
- [Explore the live demo](https://emoji-motion.phpz.org/) or run it locally:
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.
14
24
 
15
- ```sh
16
- npm run dev
17
- ```
25
+ ## 📊 Collection at a glance
26
+
27
+ | In version 0.5.0 | Count | What it covers |
28
+ | --- | ---: | --- |
29
+ | Handcrafted presets | 177 | Individually configured base emoji |
30
+ | Reviewed motion families | 16 | 123 additional base emoji sharing reviewed motions |
31
+ | Categories | 10 | Groups used to organize the collection |
32
+ | Reusable motions | 42 | Motions shared by presets and families |
33
+ | Emoji with optional SVG effects | 67 | 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,44 @@
1
+ <h1 align="center">👋</h1>
2
+
3
+ <h1 align="center">Emoji Motion</h1>
4
+
5
+ <p align="center"><a href="./README.md">English</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a></p>
6
+
7
+ <p align="center"><strong>让你的 emoji 多一点情绪。 ✨</strong></p>
8
+
9
+ <p align="center"><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> <a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license" /></a> <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></p>
10
+
11
+ 为设备上的原生 emoji 添加小巧、富有表现力的动画,支持 React、Vue、Svelte 和 Web Component。支持肤色、可选 SVG 装饰、自定义动作与 Unicode 自动分类。前往 [在线演示](https://emoji-motion.phpz.org/) 体验。
12
+
13
+ ## 📊 项目概览
14
+
15
+ | 0.5.0 版本 | 数量 | 说明 |
16
+ | --- | ---: | --- |
17
+ | 单独定制预设 | 177 | 逐个配置的基础 emoji |
18
+ | 已审核动作家族 | 16 | 另外覆盖 123 个基础 emoji |
19
+ | 类别 | 10 | 用于组织内容的分组 |
20
+ | 可复用动作 | 42 | 预设和家族共享 |
21
+ | 带可选 SVG 效果的 emoji | 67 | 基础 emoji 数量 |
22
+ | 可识别 Unicode 序列 | 5,235 | Unicode Emoji 18.0,包含肤色等变体 |
23
+
24
+ 肤色及其他变体按额外 Unicode 序列计数;没有预设或已审核家族匹配的已识别 emoji 使用分类兜底动作。
25
+
26
+ ## 📦 快速开始
27
+
28
+ ```sh
29
+ npm install @dofy/native-emoji-motion
30
+ ```
31
+
32
+ ```jsx
33
+ import { EmojiMotion } from '@dofy/native-emoji-motion/react';
34
+
35
+ <p>Hello <EmojiMotion emoji="👋" skinTone="medium" /></p>
36
+ ```
37
+
38
+ 可使用 React、Vue、Svelte 组件或 Web Component,也可通过 `enhance()` 为现有文本添加动效。完整接入、参数、肤色与自定义说明请参阅[帮助文档](https://emoji-motion.phpz.org/docs/)。
39
+
40
+ > 从 0.3.0 升级?0.4.0 移除了 `display` 参数,请使用块级容器或 CSS 控制布局。
41
+
42
+ ## 📄 许可证
43
+
44
+ [MIT](./LICENSE)。Unicode 数据适用 [Unicode License](./UNICODE-LICENSE.txt)。
@@ -0,0 +1,44 @@
1
+ <h1 align="center">👋</h1>
2
+
3
+ <h1 align="center">Emoji Motion</h1>
4
+
5
+ <p align="center"><a href="./README.md">English</a> · <a href="./README.zh-CN.md">简体中文</a> · <a href="./README.zh-TW.md">繁體中文</a> · <a href="./README.ja.md">日本語</a></p>
6
+
7
+ <p align="center"><strong>讓你的 emoji 多一點情緒。 ✨</strong></p>
8
+
9
+ <p align="center"><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> <a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license" /></a> <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></p>
10
+
11
+ 為裝置上的原生 emoji 加入小巧、富有表現力的動畫,支援 React、Vue、Svelte 和 Web Component。支援膚色、可選 SVG 裝飾、自訂動作與 Unicode 自動分類。前往[線上展示](https://emoji-motion.phpz.org/)體驗。
12
+
13
+ ## 📊 專案概覽
14
+
15
+ | 0.5.0 版本 | 數量 | 說明 |
16
+ | --- | ---: | --- |
17
+ | 個別訂製預設 | 177 | 逐一設定的基礎 emoji |
18
+ | 已審核動作家族 | 16 | 另外涵蓋 123 個基礎 emoji |
19
+ | 類別 | 10 | 用來組織內容的分組 |
20
+ | 可重複使用動作 | 42 | 預設和家族共用 |
21
+ | 帶可選 SVG 效果的 emoji | 67 | 基礎 emoji 數量 |
22
+ | 可辨識 Unicode 序列 | 5,235 | Unicode Emoji 18.0,包含膚色等變體 |
23
+
24
+ 膚色和其他變體按額外 Unicode 序列計數;沒有預設或已審核家族匹配的已辨識 emoji 使用分類備援動作。
25
+
26
+ ## 📦 快速開始
27
+
28
+ ```sh
29
+ npm install @dofy/native-emoji-motion
30
+ ```
31
+
32
+ ```jsx
33
+ import { EmojiMotion } from '@dofy/native-emoji-motion/react';
34
+
35
+ <p>Hello <EmojiMotion emoji="👋" skinTone="medium" /></p>
36
+ ```
37
+
38
+ 可使用 React、Vue、Svelte 元件或 Web Component,也可透過 `enhance()` 為現有文字加入動效。完整接入、參數、膚色與自訂說明請參閱[說明文件](https://emoji-motion.phpz.org/docs/)。
39
+
40
+ > 從 0.3.0 升級?0.4.0 移除了 `display` 參數,請使用區塊容器或 CSS 控制版面配置。
41
+
42
+ ## 📄 授權
43
+
44
+ [MIT](./LICENSE)。Unicode 資料適用 [Unicode License](./UNICODE-LICENSE.txt)。
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
2
+ <text x="50" y="78" text-anchor="middle" font-size="78" font-family="Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji, sans-serif">👋</text>
3
+ </svg>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dofy/native-emoji-motion",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Expressive, extensible animations for native emoji with optional SVG effects",
5
5
  "type": "module",
6
6
  "exports": {
@@ -60,7 +60,11 @@
60
60
  "types": "./src/index.d.ts",
61
61
  "files": [
62
62
  "src",
63
+ "assets/logo.svg",
63
64
  "README.md",
65
+ "README.zh-CN.md",
66
+ "README.zh-TW.md",
67
+ "README.ja.md",
64
68
  "LICENSE",
65
69
  "UNICODE-LICENSE.txt",
66
70
  "CHANGELOG.md"
@@ -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
@@ -51,7 +51,17 @@ export const motions = {
51
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
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
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)' } }
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
+ fruitBounce: createSpringMotion({ height: 10, rebounds: 1, duration: 850 }),
60
+ curiousTilt: { keyframes: [{ transform: 'rotate(0deg) translateY(0)' }, { transform: 'rotate(-7deg) translateY(-3%)', offset: .3 }, { transform: 'rotate(7deg) translateY(-3%)', offset: .62 }, { transform: 'rotate(0deg) translateY(0)' }], options: { duration: 1100, easing: curve }, origin: '50% 85%' },
61
+ cloudDrift: { keyframes: [{ transform: 'translate(0, 0)' }, { transform: 'translate(-5%, -4%)', offset: .35 }, { transform: 'translate(5%, -4%)', offset: .7 }, { transform: 'translate(0, 0)' }], options: { duration: 1400, easing: curve } },
62
+ 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
+ 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
+ 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%' }
55
65
  };
56
66
 
57
67
  // Groups describe semantic families; emoji entries may override the group's motion.
@@ -75,13 +85,34 @@ const skinTones = glyph => Object.fromEntries(['light', 'medium-light', 'medium'
75
85
  // A person modifier belongs before the joiner, never at the end of the sequence.
76
86
  const personSkinTones = suffix => Object.fromEntries(['light', 'medium-light', 'medium', 'medium-dark', 'dark'].map((tone, index) => [tone, `🧑${String.fromCodePoint(0x1f3fb + index)}‍${suffix}`]));
77
87
  const familyToneAliases = glyphs => Object.fromEntries(glyphs.flatMap(glyph => Object.values(skinTones(glyph)).map(variant => [variant, glyph])));
88
+ // These reviewed ZWJ families use explicit suffixes; tests check every sequence against Unicode.
89
+ const rolePeople = ['🧑', '👨', '👩'];
90
+ const roleSuffixes = ['⚕️', '🎓', '🏫', '🌾', '🔧', '🔬', '🚒', '⚖️', '✈️', '🎤'];
91
+ const roleMembers = roleSuffixes.flatMap(suffix => rolePeople.map(person => `${person}‍${suffix}`));
92
+ const roleAliases = Object.fromEntries(roleSuffixes.flatMap(suffix => rolePeople.flatMap(person => Object.values(skinTones(person)).map(variant => [`${variant}‍${suffix}`, `${person}‍${suffix}`]))));
93
+ const walkerMembers = ['🚶', '🚶‍♂️', '🚶‍♀️', '🏃‍♂️', '🏃‍♀️'];
94
+ const walkerAliases = Object.fromEntries(walkerMembers.flatMap(glyph => {
95
+ const [person, suffix = ''] = glyph.split('‍');
96
+ return Object.values(skinTones(person)).map(variant => [suffix ? `${variant}‍${suffix}` : variant, glyph]);
97
+ }));
78
98
  // Members are reviewed explicitly; no entire Unicode subgroup is assumed to move alike.
79
99
  export const families = {
100
+ fruit: { label: 'Fruit', group: 'food', motion: 'fruitBounce', members: ['🍊', '🍋', '🍐', '🍑', '🍇', '🫐', '🍒', '🥝', '🍍', '🥭'], decoration: 'sparkle' },
101
+ animalFaces: { label: 'Animal faces', group: 'nature', motion: 'curiousTilt', members: ['🐶', '🦊', '🐹', '🦝', '🐨', '🐯', '🐭', '🐻‍❄️'] },
102
+ clouds: { label: 'Clouds', group: 'nature', motion: 'cloudDrift', members: ['🌤️', '⛅', '🌥️', '🌦️', '🌫️'], aliases: { '🌤': '🌤️', '🌥': '🌥️', '🌦': '🌦️', '🌫': '🌫️' } },
103
+ celestial: { label: 'Celestial bodies', group: 'nature', motion: 'orbitFloat', members: ['🌛', '🌜', '🌝', '🪐', '🌍', '🌎', '🌏', '🌚'] },
104
+ writingTools: { label: 'Writing tools', group: 'objects', motion: 'writingTilt', members: ['✏️', '🖊️', '🖋️', '🖌️', '🖍️'], aliases: { '✏': '✏️', '🖊': '🖊️', '🖋': '🖋️', '🖌': '🖌️', '🖍': '🖍️' } },
80
105
  balls: { label: 'Balls', group: 'activities', motion: 'spring', members: ['⚾', '🥎', '🎱', '🎳'], animation: { duration: 900 }, decorations: ['impact', 'sparkle'] },
81
106
  plants: { label: 'Plants', group: 'nature', motion: 'leafSway', members: ['🌷', '🌹', '🌺', '🪻', '🌴', '🌿', '🍀'] },
82
107
  drinks: { label: 'Drinks', group: 'food', motion: 'gentleSip', members: ['🥤', '🧃', '🧋', '🍹', '🍸'] },
83
108
  flying: { label: 'Flying animals', group: 'nature', motion: 'hoverFlight', members: ['🦜', '🦉', '🦅', '🪰', '🦟'] },
84
- poses: { label: 'People in poses', group: 'people', motion: 'pose', members: ['🧎', '🧍', '🙇'], aliases: familyToneAliases(['🧎', '🧍', '🙇']) }
109
+ poses: { label: 'People in poses', group: 'people', motion: 'pose', members: ['🧎', '🧍', '🙇'], aliases: familyToneAliases(['🧎', '🧍', '🙇']) },
110
+ professionals: { label: 'Professionals', group: 'people', motion: 'pose', members: roleMembers, aliases: roleAliases },
111
+ walkers: { label: 'Walking and running', group: 'people', motion: 'steps', members: walkerMembers, aliases: walkerAliases },
112
+ seaLife: { label: 'Sea life', group: 'nature', motion: 'underwater', members: ['🐠', '🐡', '🦈', '🐋', '🐳', '🦭'], decoration: 'bubbles' },
113
+ bakery: { label: 'Bakery', group: 'food', motion: 'pastryBounce', members: ['🍞', '🥐', '🥖', '🥨', '🥯', '🥞', '🧇'] },
114
+ rail: { label: 'Rail vehicles', group: 'travel', motion: 'railRide', members: ['🚄', '🚅', '🚆', '🚇', '🚈', '🚊', '🚝', '🚞'] },
115
+ instruments: { label: 'Instruments', group: 'activities', motion: 'rhythm', members: ['🎹', '🎷', '🎺', '🪕', '🪗', '🎻', '🪈'], decoration: 'sound' }
85
116
  };
86
117
  // Variants are explicit: unrelated sequences are never silently normalized.
87
118
  export const emoji = {
@@ -193,6 +224,11 @@ const lightShape = color => ({ viewBox: '0 0 100 100', elements: [
193
224
  { 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' } }
194
225
  ] });
195
226
  export const decorations = {
227
+ bubbles: { anchor: { x: '50%', y: '50%' }, layers: [{ svg: { viewBox: '0 0 100 100', elements: [
228
+ { tag: 'circle', attrs: { cx: '21', cy: '46', r: '3', fill: 'none', stroke: '#7cafd0', 'stroke-width': '2' } },
229
+ { tag: 'circle', attrs: { cx: '76', cy: '38', r: '4', fill: 'none', stroke: '#7cafd0', 'stroke-width': '2' } },
230
+ { tag: 'circle', attrs: { cx: '80', cy: '60', r: '2', fill: 'none', stroke: '#7cafd0', 'stroke-width': '2' } }
231
+ ] }, 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 } }] },
196
232
  alertLight: { anchor: { x: '50%', y: '50%' }, layers: [{ svg: lightShape('#df625e'), style: centered,
197
233
  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)' }],
198
234
  options: { duration: 900, easing: 'cubic-bezier(.22,1,.36,1)' }
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;