react-lottie 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
+ ## [2.2.0](https://github.com/chenqingspring/react-lottie/compare/v2.1.0...v2.2.0) (2026-11-05)
6
+
7
+ ### Features
8
+
9
+ * add a `colorFilters` prop that replaces the colors of fills, strokes and solid layers by keypath, mirroring lottie-react-native's API. The filters are applied to a copy of `animationData`; changing them reloads the animation while preserving the current frame and playback state ([#144](https://github.com/chenqingspring/react-lottie/issues/144))
10
+
5
11
  ## [2.1.0](https://github.com/chenqingspring/react-lottie/compare/v2.0.1...v2.1.0) (2026-11-05)
6
12
 
7
13
  ### Features
package/README.md CHANGED
@@ -187,6 +187,34 @@ A segment also has to be reachable with `loop`/`autoplay` as usual: with
187
187
  `loop: false` and `autoplay: false` the range only plays once you flip
188
188
  `isStopped` or pass a new `segments` value.
189
189
 
190
+ ### Recolor an animation
191
+
192
+ `colorFilters` replaces the colors of fills, strokes and solid layers named by
193
+ a keypath, so one animation file can follow a theme (issue #144):
194
+
195
+ ```jsx
196
+ <Lottie
197
+ options={defaultOptions}
198
+ colorFilters={[
199
+ { keypath: 'Pin 1.Shape 2.Fill 1', color: '#4f46e5' },
200
+ { keypath: 'Background', color: 'rgb(15, 23, 42)' },
201
+ ]}
202
+ />
203
+ ```
204
+
205
+ A keypath is the dot-separated name chain from the layer down to the fill,
206
+ stroke or solid, e.g. `layer.group.fill`. For a layer inside a precomposition,
207
+ the composition name can be included (`Precomp.InnerLayer.Shape.Fill 1`) or
208
+ omitted. `*` matches one level and `**` matches any number of levels; dots in
209
+ a name can be escaped with a backslash. Colors accept hex, `rgb()`/`rgba()`,
210
+ `hsl()`/`hsla()` and the basic CSS color names. Invalid filters are ignored.
211
+
212
+ The filters are applied to `options.animationData`. Changing them reloads the
213
+ animation, because lottie-web cannot recolor a loaded animation; the current
214
+ frame and paused/playing state are preserved. Animations loaded from
215
+ `options.path` are not recolored, since the JSON is fetched by lottie-web:
216
+ load the JSON yourself and pass it as `animationData` instead.
217
+
190
218
  ### Imperative control
191
219
 
192
220
  A `ref` to the component exposes `play()`, `pause()`, `stop()`,
@@ -222,6 +250,11 @@ the object representing the animation settings that will be instantiated by body
222
250
  >
223
251
  >**rendererSettings** *required*
224
252
 
253
+ **colorFilters** *optional* [default: `[]`]
254
+
255
+ Array of `{ keypath, color }` objects that replace the colors of fills,
256
+ strokes and solid layers. See [Recolor an animation](#recolor-an-animation).
257
+
225
258
  **width** *optional* [default: `100%`]
226
259
 
227
260
  pixel value for containers width.
@@ -0,0 +1,260 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.parseColor = exports.colorFiltersAreEqual = exports.applyColorFilters = void 0;
7
+ function _toConsumableArray(r) { return _arrayWithoutHoles(r) || _iterableToArray(r) || _unsupportedIterableToArray(r) || _nonIterableSpread(); }
8
+ function _nonIterableSpread() { throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); }
9
+ function _unsupportedIterableToArray(r, a) { if (r) { if ("string" == typeof r) return _arrayLikeToArray(r, a); var t = {}.toString.call(r).slice(8, -1); return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0; } }
10
+ function _iterableToArray(r) { if ("undefined" != typeof Symbol && null != r[Symbol.iterator] || null != r["@@iterator"]) return Array.from(r); }
11
+ function _arrayWithoutHoles(r) { if (Array.isArray(r)) return _arrayLikeToArray(r); }
12
+ function _arrayLikeToArray(r, a) { (null == a || a > r.length) && (a = r.length); for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e]; return n; }
13
+ function _typeof(o) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (o) { return typeof o; } : function (o) { return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o; }, _typeof(o); }
14
+ /* `colorFilters` mirrors lottie-react-native: every filter names a keypath in
15
+ the animation (layer → group → shape item) and the color to replace there.
16
+ lottie-web has no public API for recoloring a loaded animation, so the
17
+ animation JSON is cloned with the matching colors replaced and the animation
18
+ is loaded again. The caller's animationData is never mutated, because it may
19
+ be a frozen ESM import (#144). */
20
+
21
+ var NAMED_COLORS = {
22
+ aqua: [0, 255, 255],
23
+ black: [0, 0, 0],
24
+ blue: [0, 0, 255],
25
+ cyan: [0, 255, 255],
26
+ fuchsia: [255, 0, 255],
27
+ gray: [128, 128, 128],
28
+ green: [0, 128, 0],
29
+ grey: [128, 128, 128],
30
+ lime: [0, 255, 0],
31
+ magenta: [255, 0, 255],
32
+ maroon: [128, 0, 0],
33
+ navy: [0, 0, 128],
34
+ olive: [128, 128, 0],
35
+ purple: [128, 0, 128],
36
+ red: [255, 0, 0],
37
+ silver: [192, 192, 192],
38
+ teal: [0, 128, 128],
39
+ white: [255, 255, 255],
40
+ yellow: [255, 255, 0]
41
+ };
42
+ var clampChannel = function clampChannel(value) {
43
+ return Math.min(255, Math.max(0, value));
44
+ };
45
+ var toFractions = function toFractions(channels) {
46
+ return channels.map(function (channel) {
47
+ return clampChannel(channel) / 255;
48
+ });
49
+ };
50
+ var parseHex = function parseHex(value) {
51
+ var match = /^#?([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(value);
52
+ if (!match) return null;
53
+ var digits = match[1].length <= 4 ? match[1].split('').map(function (digit) {
54
+ return digit + digit;
55
+ }) : match[1].match(/.{2}/g);
56
+
57
+ /* The fourth value is alpha; lottie keeps the animation's own alpha, so it
58
+ is ignored here. */
59
+ return toFractions(digits.slice(0, 3).map(function (digit) {
60
+ return parseInt(digit, 16);
61
+ }));
62
+ };
63
+ var parseChannel = function parseChannel(token) {
64
+ var value = parseFloat(token);
65
+ if (Number.isNaN(value)) return null;
66
+ return token.endsWith('%') ? value * 255 / 100 : value;
67
+ };
68
+ var parseRgb = function parseRgb(value) {
69
+ var match = /^rgba?\(\s*([^)]+)\)$/i.exec(value);
70
+ if (!match) return null;
71
+ var channels = match[1].split(/[\s,/]+/).filter(Boolean).slice(0, 3).map(parseChannel);
72
+ if (channels.length !== 3 || channels.some(function (channel) {
73
+ return channel === null;
74
+ })) return null;
75
+ return toFractions(channels);
76
+ };
77
+ var hueToRgb = function hueToRgb(p, q, t) {
78
+ var hue = t;
79
+ if (hue < 0) hue += 1;
80
+ if (hue > 1) hue -= 1;
81
+ if (hue < 1 / 6) return p + (q - p) * 6 * hue;
82
+ if (hue < 1 / 2) return q;
83
+ if (hue < 2 / 3) return p + (q - p) * (2 / 3 - hue) * 6;
84
+ return p;
85
+ };
86
+ var parseHsl = function parseHsl(value) {
87
+ var match = /^hsla?\(\s*([^)]+)\)$/i.exec(value);
88
+ if (!match) return null;
89
+ var parts = match[1].split(/[\s,/]+/).filter(Boolean);
90
+ if (parts.length < 3) return null;
91
+ var hue = (parseFloat(parts[0]) % 360 + 360) % 360 / 360;
92
+ var saturation = parseFloat(parts[1]) / 100;
93
+ var lightness = parseFloat(parts[2]) / 100;
94
+ if (Number.isNaN(hue) || Number.isNaN(saturation) || Number.isNaN(lightness)) {
95
+ return null;
96
+ }
97
+ if (saturation === 0) return [lightness, lightness, lightness];
98
+ var q = lightness < 0.5 ? lightness * (1 + saturation) : lightness + saturation - lightness * saturation;
99
+ var p = 2 * lightness - q;
100
+ return [hueToRgb(p, q, hue + 1 / 3), hueToRgb(p, q, hue), hueToRgb(p, q, hue - 1 / 3)];
101
+ };
102
+
103
+ /* Accepts the CSS color forms an app is likely to use for theming. Anything
104
+ else (including CSS variables) is ignored rather than crashing the render. */
105
+ var parseColor = exports.parseColor = function parseColor(value) {
106
+ if (typeof value !== 'string') return null;
107
+ var color = value.trim().toLowerCase();
108
+ if (NAMED_COLORS[color]) return toFractions(NAMED_COLORS[color]);
109
+ return parseHex(color) || parseRgb(color) || parseHsl(color);
110
+ };
111
+ var setColorArray = function setColorArray(target, color) {
112
+ if (!Array.isArray(target) || target.length < 3) return false;
113
+ target[0] = color[0];
114
+ target[1] = color[1];
115
+ target[2] = color[2];
116
+ return true;
117
+ };
118
+
119
+ /* A color property is either static (`k: [r, g, b, a]`) or animated
120
+ (`k: [{ s: [...], e: [...] }, ...]`). Replace both so an animated fill stays
121
+ recolored throughout its keyframes. */
122
+ var setColorProperty = function setColorProperty(property, color) {
123
+ if (!property || !Array.isArray(property.k) || property.k.length === 0) return false;
124
+ if (typeof property.k[0] === 'number') {
125
+ return setColorArray(property.k, color);
126
+ }
127
+ var applied = false;
128
+ property.k.forEach(function (keyframe) {
129
+ if (!keyframe || _typeof(keyframe) !== 'object') return;
130
+ if (setColorArray(keyframe.s, color)) applied = true;
131
+ if (setColorArray(keyframe.e, color)) applied = true;
132
+ });
133
+ return applied;
134
+ };
135
+ var toHex = function toHex(color) {
136
+ return "#".concat(color.map(function (channel) {
137
+ return Math.round(channel * 255).toString(16).padStart(2, '0');
138
+ }).join(''));
139
+ };
140
+
141
+ /* Solid layers store their color as a hex string instead of a color array. */
142
+ var setSolidColor = function setSolidColor(layer, color) {
143
+ if (typeof layer.sc === 'string') {
144
+ layer.sc = toHex(color);
145
+ return true;
146
+ }
147
+ return setColorArray(layer.sc, color);
148
+ };
149
+
150
+ /* Split on unescaped dots so layer names that contain a dot (`icon.dark`) can
151
+ be targeted with `icon\.dark`. */
152
+ var splitKeypath = function splitKeypath(keypath) {
153
+ var parts = [];
154
+ var current = '';
155
+ for (var index = 0; index < keypath.length; index += 1) {
156
+ var character = keypath[index];
157
+ if (character === '\\' && index + 1 < keypath.length) {
158
+ current += keypath[index + 1];
159
+ index += 1;
160
+ } else if (character === '.') {
161
+ parts.push(current);
162
+ current = '';
163
+ } else {
164
+ current += character;
165
+ }
166
+ }
167
+ parts.push(current);
168
+ return parts;
169
+ };
170
+
171
+ /* `*` matches one level and `**` matches any number of levels, matching the
172
+ keypath conventions used by lottie-ios. */
173
+ var _matchesKeypath = function matchesKeypath(pattern, path) {
174
+ if (pattern.length === 0) return path.length === 0;
175
+ if (pattern[0] === '**') {
176
+ for (var index = 0; index <= path.length; index += 1) {
177
+ if (_matchesKeypath(pattern.slice(1), path.slice(index))) return true;
178
+ }
179
+ return false;
180
+ }
181
+ if (path.length === 0) return false;
182
+ if (pattern[0] !== '*' && pattern[0] !== path[0]) return false;
183
+ return _matchesKeypath(pattern.slice(1), path.slice(1));
184
+ };
185
+ var colorFiltersAreEqual = exports.colorFiltersAreEqual = function colorFiltersAreEqual(first, second) {
186
+ if (first === second) return true;
187
+ if (!Array.isArray(first) || !Array.isArray(second) || first.length !== second.length) {
188
+ return false;
189
+ }
190
+ return first.every(function (filter, index) {
191
+ var other = second[index];
192
+ return Boolean(filter && other) && filter.keypath === other.keypath && filter.color === other.color;
193
+ });
194
+ };
195
+ var applyColorFilters = exports.applyColorFilters = function applyColorFilters(animationData, colorFilters) {
196
+ if (!animationData || !Array.isArray(colorFilters) || colorFilters.length === 0) {
197
+ return animationData;
198
+ }
199
+ var filters = colorFilters.filter(function (filter) {
200
+ return filter && typeof filter.keypath === 'string';
201
+ }).map(function (filter) {
202
+ return {
203
+ parts: splitKeypath(filter.keypath.trim()),
204
+ color: parseColor(filter.color)
205
+ };
206
+ }).filter(function (filter) {
207
+ return filter.color;
208
+ });
209
+ if (filters.length === 0) return animationData;
210
+ var data = JSON.parse(JSON.stringify(animationData));
211
+ var assets = new Map();
212
+ if (Array.isArray(data.assets)) {
213
+ data.assets.forEach(function (asset) {
214
+ if (asset && asset.id) assets.set(asset.id, asset);
215
+ });
216
+ }
217
+ var applied = false;
218
+ var recolor = function recolor(node, path) {
219
+ filters.forEach(function (filter) {
220
+ if (!_matchesKeypath(filter.parts, path)) return;
221
+ if (node.c && setColorProperty(node.c, filter.color)) applied = true;
222
+ if (node.ty === 1 && setSolidColor(node, filter.color)) applied = true;
223
+ });
224
+ };
225
+ var _walkShapes = function walkShapes(shapes, path) {
226
+ if (!Array.isArray(shapes)) return;
227
+ shapes.forEach(function (shape) {
228
+ if (!shape || _typeof(shape) !== 'object') return;
229
+ var shapePath = shape.nm ? [].concat(_toConsumableArray(path), [shape.nm]) : path;
230
+ recolor(shape, shapePath);
231
+ _walkShapes(shape.it, shapePath);
232
+ _walkShapes(shape.shapes, shapePath);
233
+ });
234
+ };
235
+
236
+ /* Precompositions are walked twice: once from the asset (so a keypath can
237
+ start at the inner layer name) and once from each layer that references
238
+ them (so `Precomp.InnerLayer...` also works). `refStack` prevents a
239
+ self-referencing composition from recursing forever. */
240
+ var _walkLayers = function walkLayers(layers, prefix, refStack) {
241
+ if (!Array.isArray(layers)) return;
242
+ layers.forEach(function (layer) {
243
+ if (!layer || _typeof(layer) !== 'object') return;
244
+ var path = layer.nm ? [].concat(_toConsumableArray(prefix), [layer.nm]) : prefix;
245
+ recolor(layer, path);
246
+ _walkShapes(layer.shapes, path);
247
+ var asset = layer.refId ? assets.get(layer.refId) : null;
248
+ if (layer.ty === 0 && asset && !refStack.includes(layer.refId)) {
249
+ _walkLayers(asset.layers, path, [].concat(_toConsumableArray(refStack), [layer.refId]));
250
+ }
251
+ });
252
+ };
253
+ _walkLayers(data.layers, [], []);
254
+ if (Array.isArray(data.assets)) {
255
+ data.assets.forEach(function (asset) {
256
+ return _walkLayers(asset.layers, [], []);
257
+ });
258
+ }
259
+ return applied ? data : animationData;
260
+ };
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ exports["default"] = void 0;
7
7
  var _react = _interopRequireDefault(require("react"));
8
8
  var _propTypes = _interopRequireDefault(require("prop-types"));
9
9
  var _lottieWeb = _interopRequireDefault(require("lottie-web"));
10
+ var _colorFilters = require("./colorFilters");
10
11
  function _interopRequireDefault(e) { return e && e.__esModule ? e : { "default": e }; }
11
12
  function _typeof(o) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (o) { return typeof o; } : function (o) { return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o; }, _typeof(o); }
12
13
  function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
@@ -56,7 +57,8 @@ var Lottie = exports["default"] = /*#__PURE__*/function (_React$Component) {
56
57
  value: function componentDidMount() {
57
58
  var _this$props = this.props,
58
59
  options = _this$props.options,
59
- eventListeners = _this$props.eventListeners;
60
+ eventListeners = _this$props.eventListeners,
61
+ colorFilters = _this$props.colorFilters;
60
62
  var loop = options.loop,
61
63
  autoplay = options.autoplay,
62
64
  animationData = options.animationData,
@@ -64,16 +66,28 @@ var Lottie = exports["default"] = /*#__PURE__*/function (_React$Component) {
64
66
  renderer = _options$renderer === void 0 ? 'svg' : _options$renderer,
65
67
  rendererSettings = options.rendererSettings,
66
68
  segments = options.segments;
69
+
70
+ /* Keep the caller's data and filter list around so a later update can tell
71
+ whether either actually changed. */
72
+ this.animationData = animationData;
73
+ this.colorFilters = colorFilters;
74
+ var filteredAnimationData = (0, _colorFilters.applyColorFilters)(animationData, colorFilters);
67
75
  this.options = {
68
76
  container: this.el,
69
77
  renderer: renderer,
70
78
  loop: loop !== false,
71
79
  autoplay: autoplay !== false,
72
80
  segments: segments !== false,
73
- animationData: animationData,
81
+ animationData: filteredAnimationData,
74
82
  rendererSettings: rendererSettings
75
83
  };
76
84
  this.options = _objectSpread(_objectSpread({}, this.options), options);
85
+ /* Spreading `options` restored the unfiltered data; use the filtered copy
86
+ after the merge. */
87
+ this.options.animationData = filteredAnimationData;
88
+ if (typeof process !== 'undefined' && process.env.NODE_ENV !== 'production' && Array.isArray(colorFilters) && colorFilters.length > 0 && !animationData) {
89
+ console.warn('[react-lottie] colorFilters requires options.animationData; ' + 'animations loaded from options.path are not recolored.');
90
+ }
77
91
  this.anim = _lottieWeb["default"].loadAnimation(this.options);
78
92
  this.registerEvents(eventListeners);
79
93
  this.setSpeed();
@@ -101,17 +115,35 @@ var Lottie = exports["default"] = /*#__PURE__*/function (_React$Component) {
101
115
  }, {
102
116
  key: "componentDidUpdate",
103
117
  value: function componentDidUpdate(prevProps) {
104
- /* Recreate the animation handle if the data or its remote path changed.
105
- Done here rather than in componentWillUpdate, which React has deprecated
106
- and which is unsafe in concurrent rendering (#77, #100, #119). */
107
- var dataChanged = this.options.animationData !== this.props.options.animationData;
118
+ /* Recreate the animation handle if the data, its remote path or the color
119
+ filters changed. Done here rather than in componentWillUpdate, which
120
+ React has deprecated and which is unsafe in concurrent rendering
121
+ (#77, #100, #119). */
122
+ var dataChanged = this.animationData !== this.props.options.animationData;
108
123
  var pathChanged = this.options.path !== this.props.options.path;
109
- if (dataChanged || pathChanged) {
124
+ var colorFiltersChanged = !(0, _colorFilters.colorFiltersAreEqual)(this.colorFilters, this.props.colorFilters);
125
+ if (dataChanged || pathChanged || colorFiltersChanged) {
126
+ var currentFrame = this.anim ? this.anim.currentFrame : 0;
127
+ var wasPaused = this.anim ? this.anim.isPaused : true;
128
+ /* lottie-web cannot recolor a loaded animation, so a filter change has to
129
+ load the data again. Only a filter-only change restores the frame. */
130
+ var colorOnlyChange = colorFiltersChanged && !dataChanged && !pathChanged;
110
131
  this.deRegisterEvents(prevProps.eventListeners);
111
132
  this.destroy();
133
+ this.animationData = this.props.options.animationData;
134
+ this.colorFilters = this.props.colorFilters;
112
135
  this.options = _objectSpread(_objectSpread({}, this.options), this.props.options);
136
+ this.options.animationData = (0, _colorFilters.applyColorFilters)(this.props.options.animationData, this.props.colorFilters);
113
137
  this.anim = _lottieWeb["default"].loadAnimation(this.options);
114
138
  this.registerEvents(this.props.eventListeners);
139
+
140
+ /* Keep the frame and playback state so a theme change does not restart
141
+ a paused or half-played animation. */
142
+ if (colorOnlyChange) {
143
+ if (this.props.segments) this.anim.playSegments(this.props.segments, true);
144
+ if (Number.isFinite(currentFrame)) this.anim.goToAndStop(currentFrame, true);
145
+ if (!wasPaused) this.anim.play();
146
+ }
115
147
  }
116
148
 
117
149
  /* Only drive playback when a playback prop asks for it. Re-rendering used
@@ -275,6 +307,10 @@ var Lottie = exports["default"] = /*#__PURE__*/function (_React$Component) {
275
307
  Lottie.propTypes = {
276
308
  eventListeners: _propTypes["default"].arrayOf(_propTypes["default"].object),
277
309
  options: _propTypes["default"].object.isRequired,
310
+ colorFilters: _propTypes["default"].arrayOf(_propTypes["default"].shape({
311
+ keypath: _propTypes["default"].string.isRequired,
312
+ color: _propTypes["default"].string.isRequired
313
+ })),
278
314
  height: _propTypes["default"].oneOfType([_propTypes["default"].string, _propTypes["default"].number]),
279
315
  width: _propTypes["default"].oneOfType([_propTypes["default"].string, _propTypes["default"].number]),
280
316
  isStopped: _propTypes["default"].bool,
@@ -292,6 +328,7 @@ Lottie.propTypes = {
292
328
  };
293
329
  Lottie.defaultProps = {
294
330
  eventListeners: [],
331
+ colorFilters: [],
295
332
  isStopped: false,
296
333
  isPaused: false,
297
334
  speed: 1,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-lottie",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "funding": "https://opencollective.com/react-lottie",
5
5
  "description": "lottie animation view for React",
6
6
  "repository": {
@@ -51,7 +51,8 @@
51
51
  "react": ">=16.8.0"
52
52
  },
53
53
  "overrides": {
54
- "cheerio": "1.0.0-rc.12"
54
+ "cheerio": "1.0.0-rc.12",
55
+ "minimatch@^10.2.2": "11.0.4"
55
56
  },
56
57
  "resolutions": {
57
58
  "cheerio": "1.0.0-rc.12"