@rootnative/inertia 0.0.0-alpha.5 → 0.0.0-alpha.6

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
@@ -4,6 +4,12 @@ All notable changes to `@rootnative/inertia` are documented here. The format fol
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ### Added
8
+
9
+ - **`useShadow` now interpolates CSS `boxShadow`.** The classic `shadow*`/`elevation` keys never reach the web renderer, so a `useShadow` elevation crossfade silently dropped its shadow on web — the platform where hover, its most common driver, matters most (gap surfaced by the RootNative UI Card migration). `ShadowConfig` gains `boxShadow?: string | BoxShadowLayer[]`: pass the CSS string form design systems store elevation tokens in (px lengths only; parsed once on the JS thread, `cubicBezier`-style — malformed tokens throw at setup rather than warning) or structured layers mirroring RN's `BoxShadowValue`. Multi-layer shadows interpolate per layer with CSS-transition padding semantics (shorter side padded with invisible layers; a genuine `inset` mismatch throws); blur clamps at 0 under springy overshoot. Emitted as a `boxShadow` style string — passed through as CSS by react-native-web and rendered natively on RN 0.76+ new architecture; keep `shadow*`/`elevation` alongside it for old-arch native. New `BoxShadowLayer` type exported from the root.
10
+
11
+ ## [0.0.0-alpha.5] - 2026-07-21
12
+
7
13
  ### Changed
8
14
 
9
15
  - **Non-worklet transformers and easings now dev-warn.** `useTransform`'s transformer overload and custom `timing.easing` functions must be worklets (`'worklet'` directive as the first statement). The previous "plain functions are auto-wrapped" promise was unfulfillable: the directive-wrapped fallback closes over the opaque function reference, not the shared values read inside it, so Reanimated cannot extract dependencies (a `useTransform` derived value silently only refreshed on React re-renders — found via a frozen TextField label float in the UI library) and native builds reject the plain function when the closure is serialized to the UI thread. The fallback wrapper remains as a web-only best effort, but both sites now `console.warn` once in dev (suppressed under Jest, where the shared stubs report every function as non-worklet). Docs (`transitions.md`, `layout.md`, `api/hooks.md`) and docstrings corrected to state the real contract.
package/dist/index.d.mts CHANGED
@@ -555,11 +555,42 @@ interface UseScrollResult {
555
555
  */
556
556
  declare function useScroll(): UseScrollResult;
557
557
 
558
+ /**
559
+ * CSS `box-shadow` parsing + pairing for `useShadow`.
560
+ *
561
+ * Design systems store web elevation tokens as CSS `box-shadow` strings
562
+ * (`'0px 1px 2px rgba(0,0,0,0.3), 0px 1px 3px 1px rgba(0,0,0,0.15)'`).
563
+ * Like `cubicBezier`, this module makes those tokens directly consumable:
564
+ * strings are parsed once on the JS thread into flat layer records the
565
+ * `useShadow` worklet can interpolate without any frame-time string work.
566
+ *
567
+ * Invalid input **throws** rather than warning — shadow tokens are
568
+ * constructed at theme/module setup, and a malformed token should fail
569
+ * loudly there, not silently render the wrong elevation.
570
+ */
571
+ /**
572
+ * One layer of a `box-shadow`, structurally mirroring React Native's
573
+ * `BoxShadowValue` (RN 0.76+ `boxShadow` style). Lengths are px numbers.
574
+ */
575
+ interface BoxShadowLayer {
576
+ offsetX: number;
577
+ offsetY: number;
578
+ /** Must be >= 0, per CSS. @default 0 */
579
+ blurRadius?: number;
580
+ /** @default 0 */
581
+ spreadDistance?: number;
582
+ /** Any color string Reanimated's `interpolateColor` accepts. @default 'black' */
583
+ color?: string;
584
+ /** @default false */
585
+ inset?: boolean;
586
+ }
587
+
558
588
  /**
559
589
  * Shape accepted on either end of a `useShadow` tween. Every field is
560
590
  * optional — only keys present on at least one side participate in the
561
591
  * output style. Mirrors the flat shadow keys on `Motion.View`'s `animate`
562
- * surface, plus the nested `shadowOffset` source.
592
+ * surface, plus the nested `shadowOffset` source and the CSS `boxShadow`
593
+ * surface.
563
594
  */
564
595
  interface ShadowConfig {
565
596
  shadowOpacity?: number;
@@ -571,6 +602,19 @@ interface ShadowConfig {
571
602
  /** Android elevation. iOS shadow consumers can leave this off. */
572
603
  elevation?: number;
573
604
  shadowColor?: string;
605
+ /**
606
+ * CSS `box-shadow` — the shadow surface on web (react-native-web passes
607
+ * it through as CSS) and on React Native 0.76+ new-architecture native.
608
+ * Accepts the CSS string form design systems store elevation tokens in
609
+ * (`'0px 1px 2px rgba(0,0,0,0.3), 0px 1px 3px 1px rgba(0,0,0,0.15)'`;
610
+ * px lengths only) or structured layers. Multi-layer shadows interpolate
611
+ * per layer; when one side has fewer layers, it is padded with invisible
612
+ * layers, CSS-transition style. A malformed string **throws** at render
613
+ * (like `cubicBezier` — token mistakes should fail loudly at setup).
614
+ * The classic `shadow*`/`elevation` keys don't reach the web renderer —
615
+ * provide `boxShadow` alongside them when the tween must show up there.
616
+ */
617
+ boxShadow?: string | readonly BoxShadowLayer[];
574
618
  }
575
619
  interface UseShadowOptions {
576
620
  /** Shadow state at `progress === 0`. */
@@ -607,6 +651,19 @@ interface UseShadowOptions {
607
651
  * `shadowColor`, `{ width: 0, height: 0 }` for `shadowOffset`). This is a
608
652
  * pure interpolator — to "animate" the shadow, drive `progress` with a
609
653
  * spring, timing, or gesture upstream.
654
+ *
655
+ * The classic `shadow*`/`elevation` keys don't render on web. When the
656
+ * tween must show up there (or on RN 0.76+ new-arch native via the CSS
657
+ * shadow model), provide `boxShadow` on both ends — CSS string tokens or
658
+ * structured layers; multi-layer shadows interpolate per layer:
659
+ *
660
+ * ```tsx
661
+ * const shadowStyle = useShadow({
662
+ * from: { boxShadow: theme.elevation.level1 }, // '0px 1px 2px rgba(0,0,0,0.3), 0px 1px 3px 1px rgba(0,0,0,0.15)'
663
+ * to: { boxShadow: theme.elevation.level2 },
664
+ * progress,
665
+ * })
666
+ * ```
610
667
  */
611
668
  declare function useShadow({ from, to, progress, }: UseShadowOptions): ReturnType<typeof useAnimatedStyle>;
612
669
 
@@ -623,4 +680,4 @@ declare function useShadow({ from, to, progress, }: UseShadowOptions): ReturnTyp
623
680
  */
624
681
  declare function useVariants<V extends Readonly<Record<string, object>>>(variants: V, initial?: keyof V & string): VariantController<keyof V & string>;
625
682
 
626
- export { AnimatableValue, type ColorStyleKey, EasingInput, type ExtrapolationMode, Motion, MotionComponent, MotionConfig, type MotionConfigProps, type MotionConfigValue, NamedTransitions, Presence, type PresenceContextValue, type ReducedMotion, type ShadowConfig, SpringTransition, TransitionConfig, TransitionInput, TransitionName, type UseColorTransitionOptions, type UseScrollResult, type UseShadowOptions, type UseTransformOptions, VariantController, buildReleaseAnimation, createMotionComponent, cubicBezier, ensureWorkletEasing, resolveAnimatableValue, resolveNamedTransition, resolveTransition, useAnimation, useBooleanSpring, useColorTransition, useMotionConfig, useMotionValue, useNamedTransitions, usePresence, useScroll, useShadow, useShouldReduceMotion, useSpring, useTransform, useVariants };
683
+ export { AnimatableValue, type BoxShadowLayer, type ColorStyleKey, EasingInput, type ExtrapolationMode, Motion, MotionComponent, MotionConfig, type MotionConfigProps, type MotionConfigValue, NamedTransitions, Presence, type PresenceContextValue, type ReducedMotion, type ShadowConfig, SpringTransition, TransitionConfig, TransitionInput, TransitionName, type UseColorTransitionOptions, type UseScrollResult, type UseShadowOptions, type UseTransformOptions, VariantController, buildReleaseAnimation, createMotionComponent, cubicBezier, ensureWorkletEasing, resolveAnimatableValue, resolveNamedTransition, resolveTransition, useAnimation, useBooleanSpring, useColorTransition, useMotionConfig, useMotionValue, useNamedTransitions, usePresence, useScroll, useShadow, useShouldReduceMotion, useSpring, useTransform, useVariants };
package/dist/index.d.ts CHANGED
@@ -555,11 +555,42 @@ interface UseScrollResult {
555
555
  */
556
556
  declare function useScroll(): UseScrollResult;
557
557
 
558
+ /**
559
+ * CSS `box-shadow` parsing + pairing for `useShadow`.
560
+ *
561
+ * Design systems store web elevation tokens as CSS `box-shadow` strings
562
+ * (`'0px 1px 2px rgba(0,0,0,0.3), 0px 1px 3px 1px rgba(0,0,0,0.15)'`).
563
+ * Like `cubicBezier`, this module makes those tokens directly consumable:
564
+ * strings are parsed once on the JS thread into flat layer records the
565
+ * `useShadow` worklet can interpolate without any frame-time string work.
566
+ *
567
+ * Invalid input **throws** rather than warning — shadow tokens are
568
+ * constructed at theme/module setup, and a malformed token should fail
569
+ * loudly there, not silently render the wrong elevation.
570
+ */
571
+ /**
572
+ * One layer of a `box-shadow`, structurally mirroring React Native's
573
+ * `BoxShadowValue` (RN 0.76+ `boxShadow` style). Lengths are px numbers.
574
+ */
575
+ interface BoxShadowLayer {
576
+ offsetX: number;
577
+ offsetY: number;
578
+ /** Must be >= 0, per CSS. @default 0 */
579
+ blurRadius?: number;
580
+ /** @default 0 */
581
+ spreadDistance?: number;
582
+ /** Any color string Reanimated's `interpolateColor` accepts. @default 'black' */
583
+ color?: string;
584
+ /** @default false */
585
+ inset?: boolean;
586
+ }
587
+
558
588
  /**
559
589
  * Shape accepted on either end of a `useShadow` tween. Every field is
560
590
  * optional — only keys present on at least one side participate in the
561
591
  * output style. Mirrors the flat shadow keys on `Motion.View`'s `animate`
562
- * surface, plus the nested `shadowOffset` source.
592
+ * surface, plus the nested `shadowOffset` source and the CSS `boxShadow`
593
+ * surface.
563
594
  */
564
595
  interface ShadowConfig {
565
596
  shadowOpacity?: number;
@@ -571,6 +602,19 @@ interface ShadowConfig {
571
602
  /** Android elevation. iOS shadow consumers can leave this off. */
572
603
  elevation?: number;
573
604
  shadowColor?: string;
605
+ /**
606
+ * CSS `box-shadow` — the shadow surface on web (react-native-web passes
607
+ * it through as CSS) and on React Native 0.76+ new-architecture native.
608
+ * Accepts the CSS string form design systems store elevation tokens in
609
+ * (`'0px 1px 2px rgba(0,0,0,0.3), 0px 1px 3px 1px rgba(0,0,0,0.15)'`;
610
+ * px lengths only) or structured layers. Multi-layer shadows interpolate
611
+ * per layer; when one side has fewer layers, it is padded with invisible
612
+ * layers, CSS-transition style. A malformed string **throws** at render
613
+ * (like `cubicBezier` — token mistakes should fail loudly at setup).
614
+ * The classic `shadow*`/`elevation` keys don't reach the web renderer —
615
+ * provide `boxShadow` alongside them when the tween must show up there.
616
+ */
617
+ boxShadow?: string | readonly BoxShadowLayer[];
574
618
  }
575
619
  interface UseShadowOptions {
576
620
  /** Shadow state at `progress === 0`. */
@@ -607,6 +651,19 @@ interface UseShadowOptions {
607
651
  * `shadowColor`, `{ width: 0, height: 0 }` for `shadowOffset`). This is a
608
652
  * pure interpolator — to "animate" the shadow, drive `progress` with a
609
653
  * spring, timing, or gesture upstream.
654
+ *
655
+ * The classic `shadow*`/`elevation` keys don't render on web. When the
656
+ * tween must show up there (or on RN 0.76+ new-arch native via the CSS
657
+ * shadow model), provide `boxShadow` on both ends — CSS string tokens or
658
+ * structured layers; multi-layer shadows interpolate per layer:
659
+ *
660
+ * ```tsx
661
+ * const shadowStyle = useShadow({
662
+ * from: { boxShadow: theme.elevation.level1 }, // '0px 1px 2px rgba(0,0,0,0.3), 0px 1px 3px 1px rgba(0,0,0,0.15)'
663
+ * to: { boxShadow: theme.elevation.level2 },
664
+ * progress,
665
+ * })
666
+ * ```
610
667
  */
611
668
  declare function useShadow({ from, to, progress, }: UseShadowOptions): ReturnType<typeof useAnimatedStyle>;
612
669
 
@@ -623,4 +680,4 @@ declare function useShadow({ from, to, progress, }: UseShadowOptions): ReturnTyp
623
680
  */
624
681
  declare function useVariants<V extends Readonly<Record<string, object>>>(variants: V, initial?: keyof V & string): VariantController<keyof V & string>;
625
682
 
626
- export { AnimatableValue, type ColorStyleKey, EasingInput, type ExtrapolationMode, Motion, MotionComponent, MotionConfig, type MotionConfigProps, type MotionConfigValue, NamedTransitions, Presence, type PresenceContextValue, type ReducedMotion, type ShadowConfig, SpringTransition, TransitionConfig, TransitionInput, TransitionName, type UseColorTransitionOptions, type UseScrollResult, type UseShadowOptions, type UseTransformOptions, VariantController, buildReleaseAnimation, createMotionComponent, cubicBezier, ensureWorkletEasing, resolveAnimatableValue, resolveNamedTransition, resolveTransition, useAnimation, useBooleanSpring, useColorTransition, useMotionConfig, useMotionValue, useNamedTransitions, usePresence, useScroll, useShadow, useShouldReduceMotion, useSpring, useTransform, useVariants };
683
+ export { AnimatableValue, type BoxShadowLayer, type ColorStyleKey, EasingInput, type ExtrapolationMode, Motion, MotionComponent, MotionConfig, type MotionConfigProps, type MotionConfigValue, NamedTransitions, Presence, type PresenceContextValue, type ReducedMotion, type ShadowConfig, SpringTransition, TransitionConfig, TransitionInput, TransitionName, type UseColorTransitionOptions, type UseScrollResult, type UseShadowOptions, type UseTransformOptions, VariantController, buildReleaseAnimation, createMotionComponent, cubicBezier, ensureWorkletEasing, resolveAnimatableValue, resolveNamedTransition, resolveTransition, useAnimation, useBooleanSpring, useColorTransition, useMotionConfig, useMotionValue, useNamedTransitions, usePresence, useScroll, useShadow, useShouldReduceMotion, useSpring, useTransform, useVariants };
package/dist/index.js CHANGED
@@ -166,6 +166,127 @@ function useScroll() {
166
166
  onScroll: handler
167
167
  };
168
168
  }
169
+
170
+ // src/internal/boxShadow.ts
171
+ var LENGTH = /^[+-]?(\d+\.?\d*|\.\d+)(px)?$/i;
172
+ var UNIT_LIKE = /^[+-]?(\d+\.?\d*|\.\d+)[a-z%]+$/i;
173
+ function invalid(input, reason) {
174
+ return new Error(
175
+ `[inertia] parseBoxShadow: ${reason} in ${JSON.stringify(input)}. Expected CSS box-shadow syntax with px lengths: '[inset] <offset-x> <offset-y> [blur] [spread] [color], ...'`
176
+ );
177
+ }
178
+ function splitLayers(input) {
179
+ const layers = [];
180
+ let depth = 0;
181
+ let start = 0;
182
+ for (let i = 0; i < input.length; i++) {
183
+ const ch = input[i];
184
+ if (ch === "(") depth++;
185
+ else if (ch === ")") depth--;
186
+ else if (ch === "," && depth === 0) {
187
+ layers.push(input.slice(start, i));
188
+ start = i + 1;
189
+ }
190
+ }
191
+ layers.push(input.slice(start));
192
+ return layers;
193
+ }
194
+ function tokenize(layer) {
195
+ const tokens = [];
196
+ let depth = 0;
197
+ let current = "";
198
+ for (const ch of layer) {
199
+ if (ch === "(") depth++;
200
+ else if (ch === ")") depth--;
201
+ if (depth === 0 && /\s/.test(ch)) {
202
+ if (current) tokens.push(current);
203
+ current = "";
204
+ } else {
205
+ current += ch;
206
+ }
207
+ }
208
+ if (current) tokens.push(current);
209
+ return tokens;
210
+ }
211
+ function parseBoxShadow(input) {
212
+ const trimmed = input.trim();
213
+ if (trimmed === "") throw invalid(input, "empty string");
214
+ if (trimmed.toLowerCase() === "none") return [];
215
+ return splitLayers(trimmed).map((layerString) => {
216
+ const tokens = tokenize(layerString.trim());
217
+ if (tokens.length === 0) throw invalid(input, "empty layer");
218
+ const lengths = [];
219
+ let color;
220
+ let inset = false;
221
+ for (const token of tokens) {
222
+ if (token.toLowerCase() === "inset") {
223
+ if (inset) throw invalid(input, "duplicate 'inset'");
224
+ inset = true;
225
+ } else if (LENGTH.test(token)) {
226
+ lengths.push(parseFloat(token));
227
+ } else if (UNIT_LIKE.test(token)) {
228
+ throw invalid(input, `unsupported unit in ${JSON.stringify(token)}`);
229
+ } else {
230
+ if (color !== void 0) throw invalid(input, "multiple colors");
231
+ color = token;
232
+ }
233
+ }
234
+ if (lengths.length < 2 || lengths.length > 4) {
235
+ throw invalid(input, `expected 2-4 lengths, got ${lengths.length}`);
236
+ }
237
+ const [offsetX = 0, offsetY = 0, blurRadius = 0, spreadDistance = 0] = lengths;
238
+ if (blurRadius < 0) throw invalid(input, "negative blur radius");
239
+ return {
240
+ offsetX,
241
+ offsetY,
242
+ blurRadius,
243
+ spreadDistance,
244
+ color: color ?? "black",
245
+ inset
246
+ };
247
+ });
248
+ }
249
+ function resolveBoxShadowInput(input) {
250
+ if (input === void 0) return [];
251
+ if (typeof input === "string") return parseBoxShadow(input);
252
+ return input.map((layer) => ({
253
+ offsetX: layer.offsetX,
254
+ offsetY: layer.offsetY,
255
+ blurRadius: layer.blurRadius ?? 0,
256
+ spreadDistance: layer.spreadDistance ?? 0,
257
+ color: layer.color ?? "black",
258
+ inset: layer.inset ?? false
259
+ }));
260
+ }
261
+ function pairBoxShadowLayers(from, to) {
262
+ const count = Math.max(from.length, to.length);
263
+ const pairs = [];
264
+ for (let i = 0; i < count; i++) {
265
+ const a = from[i];
266
+ const b = to[i];
267
+ const fromLayer = a ?? invisibleLayer(b.inset);
268
+ const toLayer = b ?? invisibleLayer(a.inset);
269
+ if (fromLayer.inset !== toLayer.inset) {
270
+ throw new Error(
271
+ `[inertia] useShadow: boxShadow layer ${i} is 'inset' on one side but not the other \u2014 inset cannot be interpolated. Give both sides the same inset-ness (pad with a transparent layer if needed).`
272
+ );
273
+ }
274
+ pairs.push({ from: fromLayer, to: toLayer });
275
+ }
276
+ return pairs;
277
+ }
278
+ function invisibleLayer(inset) {
279
+ return {
280
+ offsetX: 0,
281
+ offsetY: 0,
282
+ blurRadius: 0,
283
+ spreadDistance: 0,
284
+ color: "transparent",
285
+ inset
286
+ };
287
+ }
288
+
289
+ // src/values/useShadow.ts
169
290
  function useShadow({
170
291
  from,
171
292
  to,
@@ -176,6 +297,10 @@ function useShadow({
176
297
  const hasElevation = from.elevation !== void 0 || to.elevation !== void 0;
177
298
  const hasColor = from.shadowColor !== void 0 || to.shadowColor !== void 0;
178
299
  const hasOffset = from.shadowOffset !== void 0 || to.shadowOffset !== void 0;
300
+ const boxShadowPairs = from.boxShadow !== void 0 || to.boxShadow !== void 0 ? pairBoxShadowLayers(
301
+ resolveBoxShadowInput(from.boxShadow),
302
+ resolveBoxShadowInput(to.boxShadow)
303
+ ) : [];
179
304
  const opacityFrom = from.shadowOpacity ?? 0;
180
305
  const opacityTo = to.shadowOpacity ?? 0;
181
306
  const radiusFrom = from.shadowRadius ?? 0;
@@ -210,6 +335,31 @@ function useShadow({
210
335
  height: reactNativeReanimated.interpolate(t, [0, 1], [offsetHFrom, offsetHTo])
211
336
  };
212
337
  }
338
+ if (boxShadowPairs.length > 0) {
339
+ let css = "";
340
+ let first = true;
341
+ for (const pair of boxShadowPairs) {
342
+ const x = reactNativeReanimated.interpolate(t, [0, 1], [pair.from.offsetX, pair.to.offsetX]);
343
+ const y = reactNativeReanimated.interpolate(t, [0, 1], [pair.from.offsetY, pair.to.offsetY]);
344
+ const blur = Math.max(
345
+ 0,
346
+ reactNativeReanimated.interpolate(t, [0, 1], [pair.from.blurRadius, pair.to.blurRadius])
347
+ );
348
+ const spread = reactNativeReanimated.interpolate(
349
+ t,
350
+ [0, 1],
351
+ [pair.from.spreadDistance, pair.to.spreadDistance]
352
+ );
353
+ const color = reactNativeReanimated.interpolateColor(
354
+ t,
355
+ [0, 1],
356
+ [pair.from.color, pair.to.color]
357
+ );
358
+ css += (first ? "" : ", ") + (pair.from.inset ? "inset " : "") + `${x}px ${y}px ${blur}px ${spread}px ${color}`;
359
+ first = false;
360
+ }
361
+ out.boxShadow = css;
362
+ }
213
363
  return out;
214
364
  });
215
365
  }
package/dist/index.mjs CHANGED
@@ -171,6 +171,127 @@ function useScroll() {
171
171
  onScroll: handler
172
172
  };
173
173
  }
174
+
175
+ // src/internal/boxShadow.ts
176
+ var LENGTH = /^[+-]?(\d+\.?\d*|\.\d+)(px)?$/i;
177
+ var UNIT_LIKE = /^[+-]?(\d+\.?\d*|\.\d+)[a-z%]+$/i;
178
+ function invalid(input, reason) {
179
+ return new Error(
180
+ `[inertia] parseBoxShadow: ${reason} in ${JSON.stringify(input)}. Expected CSS box-shadow syntax with px lengths: '[inset] <offset-x> <offset-y> [blur] [spread] [color], ...'`
181
+ );
182
+ }
183
+ function splitLayers(input) {
184
+ const layers = [];
185
+ let depth = 0;
186
+ let start = 0;
187
+ for (let i = 0; i < input.length; i++) {
188
+ const ch = input[i];
189
+ if (ch === "(") depth++;
190
+ else if (ch === ")") depth--;
191
+ else if (ch === "," && depth === 0) {
192
+ layers.push(input.slice(start, i));
193
+ start = i + 1;
194
+ }
195
+ }
196
+ layers.push(input.slice(start));
197
+ return layers;
198
+ }
199
+ function tokenize(layer) {
200
+ const tokens = [];
201
+ let depth = 0;
202
+ let current = "";
203
+ for (const ch of layer) {
204
+ if (ch === "(") depth++;
205
+ else if (ch === ")") depth--;
206
+ if (depth === 0 && /\s/.test(ch)) {
207
+ if (current) tokens.push(current);
208
+ current = "";
209
+ } else {
210
+ current += ch;
211
+ }
212
+ }
213
+ if (current) tokens.push(current);
214
+ return tokens;
215
+ }
216
+ function parseBoxShadow(input) {
217
+ const trimmed = input.trim();
218
+ if (trimmed === "") throw invalid(input, "empty string");
219
+ if (trimmed.toLowerCase() === "none") return [];
220
+ return splitLayers(trimmed).map((layerString) => {
221
+ const tokens = tokenize(layerString.trim());
222
+ if (tokens.length === 0) throw invalid(input, "empty layer");
223
+ const lengths = [];
224
+ let color;
225
+ let inset = false;
226
+ for (const token of tokens) {
227
+ if (token.toLowerCase() === "inset") {
228
+ if (inset) throw invalid(input, "duplicate 'inset'");
229
+ inset = true;
230
+ } else if (LENGTH.test(token)) {
231
+ lengths.push(parseFloat(token));
232
+ } else if (UNIT_LIKE.test(token)) {
233
+ throw invalid(input, `unsupported unit in ${JSON.stringify(token)}`);
234
+ } else {
235
+ if (color !== void 0) throw invalid(input, "multiple colors");
236
+ color = token;
237
+ }
238
+ }
239
+ if (lengths.length < 2 || lengths.length > 4) {
240
+ throw invalid(input, `expected 2-4 lengths, got ${lengths.length}`);
241
+ }
242
+ const [offsetX = 0, offsetY = 0, blurRadius = 0, spreadDistance = 0] = lengths;
243
+ if (blurRadius < 0) throw invalid(input, "negative blur radius");
244
+ return {
245
+ offsetX,
246
+ offsetY,
247
+ blurRadius,
248
+ spreadDistance,
249
+ color: color ?? "black",
250
+ inset
251
+ };
252
+ });
253
+ }
254
+ function resolveBoxShadowInput(input) {
255
+ if (input === void 0) return [];
256
+ if (typeof input === "string") return parseBoxShadow(input);
257
+ return input.map((layer) => ({
258
+ offsetX: layer.offsetX,
259
+ offsetY: layer.offsetY,
260
+ blurRadius: layer.blurRadius ?? 0,
261
+ spreadDistance: layer.spreadDistance ?? 0,
262
+ color: layer.color ?? "black",
263
+ inset: layer.inset ?? false
264
+ }));
265
+ }
266
+ function pairBoxShadowLayers(from, to) {
267
+ const count = Math.max(from.length, to.length);
268
+ const pairs = [];
269
+ for (let i = 0; i < count; i++) {
270
+ const a = from[i];
271
+ const b = to[i];
272
+ const fromLayer = a ?? invisibleLayer(b.inset);
273
+ const toLayer = b ?? invisibleLayer(a.inset);
274
+ if (fromLayer.inset !== toLayer.inset) {
275
+ throw new Error(
276
+ `[inertia] useShadow: boxShadow layer ${i} is 'inset' on one side but not the other \u2014 inset cannot be interpolated. Give both sides the same inset-ness (pad with a transparent layer if needed).`
277
+ );
278
+ }
279
+ pairs.push({ from: fromLayer, to: toLayer });
280
+ }
281
+ return pairs;
282
+ }
283
+ function invisibleLayer(inset) {
284
+ return {
285
+ offsetX: 0,
286
+ offsetY: 0,
287
+ blurRadius: 0,
288
+ spreadDistance: 0,
289
+ color: "transparent",
290
+ inset
291
+ };
292
+ }
293
+
294
+ // src/values/useShadow.ts
174
295
  function useShadow({
175
296
  from,
176
297
  to,
@@ -181,6 +302,10 @@ function useShadow({
181
302
  const hasElevation = from.elevation !== void 0 || to.elevation !== void 0;
182
303
  const hasColor = from.shadowColor !== void 0 || to.shadowColor !== void 0;
183
304
  const hasOffset = from.shadowOffset !== void 0 || to.shadowOffset !== void 0;
305
+ const boxShadowPairs = from.boxShadow !== void 0 || to.boxShadow !== void 0 ? pairBoxShadowLayers(
306
+ resolveBoxShadowInput(from.boxShadow),
307
+ resolveBoxShadowInput(to.boxShadow)
308
+ ) : [];
184
309
  const opacityFrom = from.shadowOpacity ?? 0;
185
310
  const opacityTo = to.shadowOpacity ?? 0;
186
311
  const radiusFrom = from.shadowRadius ?? 0;
@@ -215,6 +340,31 @@ function useShadow({
215
340
  height: interpolate(t, [0, 1], [offsetHFrom, offsetHTo])
216
341
  };
217
342
  }
343
+ if (boxShadowPairs.length > 0) {
344
+ let css = "";
345
+ let first = true;
346
+ for (const pair of boxShadowPairs) {
347
+ const x = interpolate(t, [0, 1], [pair.from.offsetX, pair.to.offsetX]);
348
+ const y = interpolate(t, [0, 1], [pair.from.offsetY, pair.to.offsetY]);
349
+ const blur = Math.max(
350
+ 0,
351
+ interpolate(t, [0, 1], [pair.from.blurRadius, pair.to.blurRadius])
352
+ );
353
+ const spread = interpolate(
354
+ t,
355
+ [0, 1],
356
+ [pair.from.spreadDistance, pair.to.spreadDistance]
357
+ );
358
+ const color = interpolateColor(
359
+ t,
360
+ [0, 1],
361
+ [pair.from.color, pair.to.color]
362
+ );
363
+ css += (first ? "" : ", ") + (pair.from.inset ? "inset " : "") + `${x}px ${y}px ${blur}px ${spread}px ${color}`;
364
+ first = false;
365
+ }
366
+ out.boxShadow = css;
367
+ }
218
368
  return out;
219
369
  });
220
370
  }
package/llms.txt CHANGED
@@ -51,7 +51,7 @@ import { MotionScrollView } from '@rootnative/inertia/scroll-view'
51
51
  - `useSpring(target, config?)` — spring-only shorthand. Animates a `SharedValue<number>` toward `target` with react-spring vocab (or a spring-typed registered `TransitionName`; non-spring names warn and fall back to the default spring). `target` may be a plain number (effect-driven) or a `SharedValue<number>` (UI-thread reaction); the latter is the gesture-smoothing path.
52
52
  - `useBooleanSpring(active, config?)` — sugar over `useSpring` for the recurring "spring 0↔1 progress from a boolean" shape (checkbox checks, accordion expansions, drawer open/closed). Identical mechanics to `useSpring(active ? 1 : 0, config)` — the named form so the call site reads as the boolean it represents.
53
53
  - `useTransform(value, inputRange, outputRange, options?)` / `useTransform(transformer)` — interpolate a numeric shared value onto a number or color range, or derive any value from any number of shared values via a worklet. Non-worklet transformers are auto-wrapped.
54
- - `useShadow({ from, to, progress })` — pure value-layer interpolator between two `ShadowConfig`s (`shadowOpacity` / `shadowRadius` / `shadowOffset` / `elevation` / `shadowColor`) driven by a `SharedValue<number>` (0→1). Returns an animated style fragment to spread onto `style`; only emits keys present on either side, absent sides default to natural zero. The hook does not animate on its own — drive `progress` with a spring, a scroll-derived `useTransform`, or any other shared value source.
54
+ - `useShadow({ from, to, progress })` — pure value-layer interpolator between two `ShadowConfig`s (`shadowOpacity` / `shadowRadius` / `shadowOffset` / `elevation` / `shadowColor`, plus `boxShadow` as a CSS string or `BoxShadowLayer[]` — the shadow surface on web and RN 0.76+ new-arch; multi-layer, CSS-transition padding semantics, malformed strings throw at setup) driven by a `SharedValue<number>` (0→1). Returns an animated style fragment to spread onto `style`; only emits keys present on either side, absent sides default to natural zero. The hook does not animate on its own — drive `progress` with a spring, a scroll-derived `useTransform`, or any other shared value source.
55
55
  - `useColorTransition(progress, [from, to], options?)` — pure value-layer interpolator for a single color channel, driven by a `SharedValue<number>` (0→1). Returns an animated style fragment with one color key (default `backgroundColor`; configurable via `options.key` to `color` / `borderColor` / `tintColor` / `shadowColor` / per-side border colors). For raw `SharedValue<string>` output, use `useTransform(progress, [0, 1], [from, to])` instead.
56
56
  - `useScroll()` — returns `{ scrollX, scrollY, onScroll }` for use with `Motion.ScrollView`. Scroll events fire on the UI thread.
57
57
  - `createMotionComponent<C>(C)` — wrap any component with the same Motion prop surface, inferring style from `C`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rootnative/inertia",
3
- "version": "0.0.0-alpha.5",
3
+ "version": "0.0.0-alpha.6",
4
4
  "description": "Declarative animation primitives for React Native, built on react-native-reanimated.",
5
5
  "license": "MIT",
6
6
  "author": "RootNative",
package/src/index.ts CHANGED
@@ -52,6 +52,7 @@ export {
52
52
  useVariants,
53
53
  } from './values'
54
54
  export type {
55
+ BoxShadowLayer,
55
56
  ColorStyleKey,
56
57
  ExtrapolationMode,
57
58
  ShadowConfig,
@@ -0,0 +1,204 @@
1
+ /**
2
+ * CSS `box-shadow` parsing + pairing for `useShadow`.
3
+ *
4
+ * Design systems store web elevation tokens as CSS `box-shadow` strings
5
+ * (`'0px 1px 2px rgba(0,0,0,0.3), 0px 1px 3px 1px rgba(0,0,0,0.15)'`).
6
+ * Like `cubicBezier`, this module makes those tokens directly consumable:
7
+ * strings are parsed once on the JS thread into flat layer records the
8
+ * `useShadow` worklet can interpolate without any frame-time string work.
9
+ *
10
+ * Invalid input **throws** rather than warning — shadow tokens are
11
+ * constructed at theme/module setup, and a malformed token should fail
12
+ * loudly there, not silently render the wrong elevation.
13
+ */
14
+
15
+ /**
16
+ * One layer of a `box-shadow`, structurally mirroring React Native's
17
+ * `BoxShadowValue` (RN 0.76+ `boxShadow` style). Lengths are px numbers.
18
+ */
19
+ export interface BoxShadowLayer {
20
+ offsetX: number
21
+ offsetY: number
22
+ /** Must be >= 0, per CSS. @default 0 */
23
+ blurRadius?: number
24
+ /** @default 0 */
25
+ spreadDistance?: number
26
+ /** Any color string Reanimated's `interpolateColor` accepts. @default 'black' */
27
+ color?: string
28
+ /** @default false */
29
+ inset?: boolean
30
+ }
31
+
32
+ /** A layer with every field resolved to a concrete value. */
33
+ export interface ResolvedBoxShadowLayer {
34
+ offsetX: number
35
+ offsetY: number
36
+ blurRadius: number
37
+ spreadDistance: number
38
+ color: string
39
+ inset: boolean
40
+ }
41
+
42
+ const LENGTH = /^[+-]?(\d+\.?\d*|\.\d+)(px)?$/i
43
+ const UNIT_LIKE = /^[+-]?(\d+\.?\d*|\.\d+)[a-z%]+$/i
44
+
45
+ function invalid(input: string, reason: string): Error {
46
+ return new Error(
47
+ `[inertia] parseBoxShadow: ${reason} in ${JSON.stringify(input)}. ` +
48
+ 'Expected CSS box-shadow syntax with px lengths: ' +
49
+ "'[inset] <offset-x> <offset-y> [blur] [spread] [color], ...'",
50
+ )
51
+ }
52
+
53
+ /**
54
+ * Split a box-shadow string into layer strings on top-level commas —
55
+ * commas inside color functions (`rgba(0, 0, 0, 0.3)`) don't split.
56
+ */
57
+ function splitLayers(input: string): string[] {
58
+ const layers: string[] = []
59
+ let depth = 0
60
+ let start = 0
61
+ for (let i = 0; i < input.length; i++) {
62
+ const ch = input[i]
63
+ if (ch === '(') depth++
64
+ else if (ch === ')') depth--
65
+ else if (ch === ',' && depth === 0) {
66
+ layers.push(input.slice(start, i))
67
+ start = i + 1
68
+ }
69
+ }
70
+ layers.push(input.slice(start))
71
+ return layers
72
+ }
73
+
74
+ /**
75
+ * Split one layer into whitespace-separated tokens, keeping color
76
+ * functions (which may contain spaces: `rgb(0 0 0 / 40%)`) as one token.
77
+ */
78
+ function tokenize(layer: string): string[] {
79
+ const tokens: string[] = []
80
+ let depth = 0
81
+ let current = ''
82
+ for (const ch of layer) {
83
+ if (ch === '(') depth++
84
+ else if (ch === ')') depth--
85
+ if (depth === 0 && /\s/.test(ch)) {
86
+ if (current) tokens.push(current)
87
+ current = ''
88
+ } else {
89
+ current += ch
90
+ }
91
+ }
92
+ if (current) tokens.push(current)
93
+ return tokens
94
+ }
95
+
96
+ /**
97
+ * Parse a CSS `box-shadow` string into resolved layers. `'none'` parses to
98
+ * an empty list. Only px (and unitless) lengths are supported — other units
99
+ * depend on font/viewport context that a style value can't resolve.
100
+ */
101
+ export function parseBoxShadow(input: string): ResolvedBoxShadowLayer[] {
102
+ const trimmed = input.trim()
103
+ if (trimmed === '') throw invalid(input, 'empty string')
104
+ if (trimmed.toLowerCase() === 'none') return []
105
+
106
+ return splitLayers(trimmed).map((layerString) => {
107
+ const tokens = tokenize(layerString.trim())
108
+ if (tokens.length === 0) throw invalid(input, 'empty layer')
109
+
110
+ const lengths: number[] = []
111
+ let color: string | undefined
112
+ let inset = false
113
+ for (const token of tokens) {
114
+ if (token.toLowerCase() === 'inset') {
115
+ if (inset) throw invalid(input, "duplicate 'inset'")
116
+ inset = true
117
+ } else if (LENGTH.test(token)) {
118
+ lengths.push(parseFloat(token))
119
+ } else if (UNIT_LIKE.test(token)) {
120
+ throw invalid(input, `unsupported unit in ${JSON.stringify(token)}`)
121
+ } else {
122
+ if (color !== undefined) throw invalid(input, 'multiple colors')
123
+ color = token
124
+ }
125
+ }
126
+
127
+ if (lengths.length < 2 || lengths.length > 4) {
128
+ throw invalid(input, `expected 2-4 lengths, got ${lengths.length}`)
129
+ }
130
+ // The `= 0` on the offsets never fires (length >= 2 is validated
131
+ // above); it's here for noUncheckedIndexedAccess.
132
+ const [offsetX = 0, offsetY = 0, blurRadius = 0, spreadDistance = 0] =
133
+ lengths
134
+ if (blurRadius < 0) throw invalid(input, 'negative blur radius')
135
+
136
+ return {
137
+ offsetX,
138
+ offsetY,
139
+ blurRadius,
140
+ spreadDistance,
141
+ color: color ?? 'black',
142
+ inset,
143
+ }
144
+ })
145
+ }
146
+
147
+ /** Resolve either input form (CSS string or structured layers) to layers. */
148
+ export function resolveBoxShadowInput(
149
+ input: string | readonly BoxShadowLayer[] | undefined,
150
+ ): ResolvedBoxShadowLayer[] {
151
+ if (input === undefined) return []
152
+ if (typeof input === 'string') return parseBoxShadow(input)
153
+ return input.map((layer) => ({
154
+ offsetX: layer.offsetX,
155
+ offsetY: layer.offsetY,
156
+ blurRadius: layer.blurRadius ?? 0,
157
+ spreadDistance: layer.spreadDistance ?? 0,
158
+ color: layer.color ?? 'black',
159
+ inset: layer.inset ?? false,
160
+ }))
161
+ }
162
+
163
+ /**
164
+ * Pair up `from`/`to` layer lists for interpolation, CSS-transition style:
165
+ * the shorter list is padded with an invisible layer (all lengths 0,
166
+ * transparent color) matching the counterpart's `inset` flag. A genuine
167
+ * `inset` mismatch between paired layers is not interpolable and throws.
168
+ */
169
+ export function pairBoxShadowLayers(
170
+ from: ResolvedBoxShadowLayer[],
171
+ to: ResolvedBoxShadowLayer[],
172
+ ): Array<{ from: ResolvedBoxShadowLayer; to: ResolvedBoxShadowLayer }> {
173
+ const count = Math.max(from.length, to.length)
174
+ const pairs: Array<{
175
+ from: ResolvedBoxShadowLayer
176
+ to: ResolvedBoxShadowLayer
177
+ }> = []
178
+ for (let i = 0; i < count; i++) {
179
+ const a = from[i]
180
+ const b = to[i]
181
+ const fromLayer = a ?? invisibleLayer(b!.inset)
182
+ const toLayer = b ?? invisibleLayer(a!.inset)
183
+ if (fromLayer.inset !== toLayer.inset) {
184
+ throw new Error(
185
+ `[inertia] useShadow: boxShadow layer ${i} is 'inset' on one side ` +
186
+ 'but not the other — inset cannot be interpolated. Give both ' +
187
+ 'sides the same inset-ness (pad with a transparent layer if needed).',
188
+ )
189
+ }
190
+ pairs.push({ from: fromLayer, to: toLayer })
191
+ }
192
+ return pairs
193
+ }
194
+
195
+ function invisibleLayer(inset: boolean): ResolvedBoxShadowLayer {
196
+ return {
197
+ offsetX: 0,
198
+ offsetY: 0,
199
+ blurRadius: 0,
200
+ spreadDistance: 0,
201
+ color: 'transparent',
202
+ inset,
203
+ }
204
+ }
@@ -20,6 +20,7 @@ export {
20
20
  export { useScroll, type UseScrollResult } from './useScroll'
21
21
  export {
22
22
  useShadow,
23
+ type BoxShadowLayer,
23
24
  type ShadowConfig,
24
25
  type UseShadowOptions,
25
26
  } from './useShadow'
@@ -4,12 +4,20 @@ import {
4
4
  useAnimatedStyle,
5
5
  type SharedValue,
6
6
  } from 'react-native-reanimated'
7
+ import {
8
+ pairBoxShadowLayers,
9
+ resolveBoxShadowInput,
10
+ type BoxShadowLayer,
11
+ } from '../internal/boxShadow'
12
+
13
+ export type { BoxShadowLayer }
7
14
 
8
15
  /**
9
16
  * Shape accepted on either end of a `useShadow` tween. Every field is
10
17
  * optional — only keys present on at least one side participate in the
11
18
  * output style. Mirrors the flat shadow keys on `Motion.View`'s `animate`
12
- * surface, plus the nested `shadowOffset` source.
19
+ * surface, plus the nested `shadowOffset` source and the CSS `boxShadow`
20
+ * surface.
13
21
  */
14
22
  export interface ShadowConfig {
15
23
  shadowOpacity?: number
@@ -18,6 +26,19 @@ export interface ShadowConfig {
18
26
  /** Android elevation. iOS shadow consumers can leave this off. */
19
27
  elevation?: number
20
28
  shadowColor?: string
29
+ /**
30
+ * CSS `box-shadow` — the shadow surface on web (react-native-web passes
31
+ * it through as CSS) and on React Native 0.76+ new-architecture native.
32
+ * Accepts the CSS string form design systems store elevation tokens in
33
+ * (`'0px 1px 2px rgba(0,0,0,0.3), 0px 1px 3px 1px rgba(0,0,0,0.15)'`;
34
+ * px lengths only) or structured layers. Multi-layer shadows interpolate
35
+ * per layer; when one side has fewer layers, it is padded with invisible
36
+ * layers, CSS-transition style. A malformed string **throws** at render
37
+ * (like `cubicBezier` — token mistakes should fail loudly at setup).
38
+ * The classic `shadow*`/`elevation` keys don't reach the web renderer —
39
+ * provide `boxShadow` alongside them when the tween must show up there.
40
+ */
41
+ boxShadow?: string | readonly BoxShadowLayer[]
21
42
  }
22
43
 
23
44
  export interface UseShadowOptions {
@@ -56,6 +77,19 @@ export interface UseShadowOptions {
56
77
  * `shadowColor`, `{ width: 0, height: 0 }` for `shadowOffset`). This is a
57
78
  * pure interpolator — to "animate" the shadow, drive `progress` with a
58
79
  * spring, timing, or gesture upstream.
80
+ *
81
+ * The classic `shadow*`/`elevation` keys don't render on web. When the
82
+ * tween must show up there (or on RN 0.76+ new-arch native via the CSS
83
+ * shadow model), provide `boxShadow` on both ends — CSS string tokens or
84
+ * structured layers; multi-layer shadows interpolate per layer:
85
+ *
86
+ * ```tsx
87
+ * const shadowStyle = useShadow({
88
+ * from: { boxShadow: theme.elevation.level1 }, // '0px 1px 2px rgba(0,0,0,0.3), 0px 1px 3px 1px rgba(0,0,0,0.15)'
89
+ * to: { boxShadow: theme.elevation.level2 },
90
+ * progress,
91
+ * })
92
+ * ```
59
93
  */
60
94
  export function useShadow({
61
95
  from,
@@ -76,6 +110,17 @@ export function useShadow({
76
110
  const hasOffset =
77
111
  from.shadowOffset !== undefined || to.shadowOffset !== undefined
78
112
 
113
+ // boxShadow layers: parse/pair once on the JS thread into flat records so
114
+ // the worklet only interpolates numbers/colors and concatenates — no
115
+ // frame-time parsing. `[]` when neither side provides the key.
116
+ const boxShadowPairs =
117
+ from.boxShadow !== undefined || to.boxShadow !== undefined
118
+ ? pairBoxShadowLayers(
119
+ resolveBoxShadowInput(from.boxShadow),
120
+ resolveBoxShadowInput(to.boxShadow),
121
+ )
122
+ : []
123
+
79
124
  const opacityFrom = from.shadowOpacity ?? 0
80
125
  const opacityTo = to.shadowOpacity ?? 0
81
126
  const radiusFrom = from.shadowRadius ?? 0
@@ -111,6 +156,36 @@ export function useShadow({
111
156
  height: interpolate(t, [0, 1], [offsetHFrom, offsetHTo]),
112
157
  }
113
158
  }
159
+ if (boxShadowPairs.length > 0) {
160
+ let css = ''
161
+ let first = true
162
+ for (const pair of boxShadowPairs) {
163
+ const x = interpolate(t, [0, 1], [pair.from.offsetX, pair.to.offsetX])
164
+ const y = interpolate(t, [0, 1], [pair.from.offsetY, pair.to.offsetY])
165
+ // Blur can't go negative (invalid CSS) even if a springy driver
166
+ // overshoots below 0.
167
+ const blur = Math.max(
168
+ 0,
169
+ interpolate(t, [0, 1], [pair.from.blurRadius, pair.to.blurRadius]),
170
+ )
171
+ const spread = interpolate(
172
+ t,
173
+ [0, 1],
174
+ [pair.from.spreadDistance, pair.to.spreadDistance],
175
+ )
176
+ const color = interpolateColor(
177
+ t,
178
+ [0, 1],
179
+ [pair.from.color, pair.to.color],
180
+ )
181
+ css +=
182
+ (first ? '' : ', ') +
183
+ (pair.from.inset ? 'inset ' : '') +
184
+ `${x}px ${y}px ${blur}px ${spread}px ${color}`
185
+ first = false
186
+ }
187
+ out.boxShadow = css
188
+ }
114
189
  return out
115
190
  })
116
191
  }