()(['primary', 'info', 'danger']);
test('sizes colors and modes', async () => {
const locator = renderVisual(
@@ -52,7 +48,7 @@ test.each(visualAppearances)('theme matrix: $theme $mode', async (appearance) =>
const scene = renderVisual(
-
+
diff --git a/packages/@luke-ui/react/src/recipes/icon.css.ts b/packages/@luke-ui/react/src/recipes/icon.css.ts
index c8711807..42e7a43d 100644
--- a/packages/@luke-ui/react/src/recipes/icon.css.ts
+++ b/packages/@luke-ui/react/src/recipes/icon.css.ts
@@ -1,6 +1,6 @@
import type { RecipeVariants } from '@vanilla-extract/recipes';
import { recipeInLayer } from '../styles/layered-style.css.js';
-import { vars } from '../styles/vars.css.js';
+import { vars } from '../theme/contract.css.js';
/** Shared size dimensions for Icon and LoadingSpinner (icon-aligned sizing). */
export const iconSizeVariants = {
diff --git a/packages/@luke-ui/react/src/recipes/index.ts b/packages/@luke-ui/react/src/recipes/index.ts
index 49f46e7a..00806596 100644
--- a/packages/@luke-ui/react/src/recipes/index.ts
+++ b/packages/@luke-ui/react/src/recipes/index.ts
@@ -7,17 +7,9 @@ export type { IconButtonVariants } from '../recipes/icon-button.css.js';
export { iconButton } from '../recipes/icon-button.css.js';
export type { LinkVariants } from '../recipes/link.css.js';
export { link } from '../recipes/link.css.js';
-export {
- loadingSkeleton,
- skeletonAnimationName,
- skeletonBorderRadiusVar,
-} from '../recipes/loading-skeleton.css.js';
+export { loadingSkeleton } from '../recipes/loading-skeleton.css.js';
export type { LoadingSpinnerVariants } from '../recipes/loading-spinner.css.js';
-export {
- rubberBandAnimationName,
- spinAnimationName,
- spinner as loadingSpinner,
-} from '../recipes/loading-spinner.css.js';
+export { spinner as loadingSpinner } from '../recipes/loading-spinner.css.js';
export type {
TextAlign,
TextColor,
diff --git a/packages/@luke-ui/react/src/recipes/loading-skeleton.browser.test.ts b/packages/@luke-ui/react/src/recipes/loading-skeleton.browser.test.ts
new file mode 100644
index 00000000..11e21486
--- /dev/null
+++ b/packages/@luke-ui/react/src/recipes/loading-skeleton.browser.test.ts
@@ -0,0 +1,152 @@
+import '@luke-ui/react/themes/elmo.css';
+import '@luke-ui/react/themes/machined-edge.css';
+import { afterEach, expect, test } from 'vite-plus/test';
+import { cdp } from 'vite-plus/test/context';
+import { contrastRatio, parseColor } from '../theme/color.js';
+import { themeRootClassName } from '../theme/index.js';
+import { elmoThemeClassName, machinedEdgeThemeClassName } from '../themes/index.js';
+import { loadingSkeleton } from './loading-skeleton.css.js';
+
+let root: HTMLElement | undefined;
+
+const themeCases = [
+ { className: machinedEdgeThemeClassName, name: 'Machined edge' },
+ { className: elmoThemeClassName, name: 'ELMO' },
+] as const;
+
+afterEach(async () => {
+ root?.remove();
+ root = undefined;
+ await setEmulatedMedia();
+});
+
+test('forced colors keeps the static skeleton surface visible', async () => {
+ await setEmulatedMedia('forced-colors', 'active');
+ const skeleton = mountInlineSkeleton();
+ const style = getComputedStyle(skeleton);
+
+ expect(style.animationName).toBe('none');
+ expect(style.backgroundColor).toBe(resolveSystemColor('CanvasText'));
+ expect(style.forcedColorAdjust).toBe('none');
+});
+
+test('reduced motion keeps the skeleton surface without a running animation', async () => {
+ await setEmulatedMedia('prefers-reduced-motion', 'reduce');
+ const skeleton = mountInlineSkeleton();
+ const style = getComputedStyle(skeleton);
+
+ expect(style.animationName).toBe('none');
+ expect(style.backgroundColor).not.toBe('rgba(0, 0, 0, 0)');
+});
+
+for (const theme of themeCases) {
+ for (const mode of ['light', 'dark'] as const) {
+ test(`${theme.name} ${mode} keeps both pulse extremes distinct from the canvas`, async () => {
+ const skeleton = mountInlineSkeleton(theme.className, mode);
+ const canvasColor = getComputedStyle(requireRoot()).backgroundColor;
+ const animation = requireAnimation(skeleton);
+
+ const brightRatio = await samplePulseContrast(skeleton, animation, canvasColor, 50);
+ const dimRatio = await samplePulseContrast(skeleton, animation, canvasColor, 1050);
+
+ expect(Math.min(brightRatio, dimRatio)).toBeGreaterThanOrEqual(1.4);
+ });
+ }
+}
+
+function mountInlineSkeleton(
+ themeClassName = machinedEdgeThemeClassName,
+ mode: 'light' | 'dark' = 'light',
+) {
+ root = document.body.appendChild(document.createElement('div'));
+ root.className = `${themeRootClassName} ${themeClassName}`;
+ root.dataset.colorMode = mode;
+ root.style.backgroundColor = 'var(--luke-color-surface-canvas)';
+ const skeleton = root.appendChild(document.createElement('span'));
+ skeleton.className = loadingSkeleton;
+ skeleton.dataset.skeletonInline = '';
+ return skeleton;
+}
+
+function requireRoot() {
+ if (!root) throw new Error('Expected theme root.');
+ return root;
+}
+
+function requireAnimation(element: Element) {
+ const animation = element.getAnimations()[0];
+ if (!animation) throw new Error('Expected skeleton animation.');
+ return animation;
+}
+
+async function samplePulseContrast(
+ element: Element,
+ animation: Animation,
+ canvasColor: string,
+ activeTime: number,
+) {
+ animation.pause();
+ animation.currentTime = 500 + activeTime;
+ await new Promise(requestAnimationFrame);
+ const style = getComputedStyle(element);
+ return contrastRatio(
+ parseColor(rgbToHex(applyBrightness(style.backgroundColor, brightnessValue(style.filter)))),
+ parseColor(rgbToHex(cssColorToRgb(canvasColor))),
+ );
+}
+
+function brightnessValue(filter: string) {
+ const match = /^brightness\((?[\d.]+)\)$/.exec(filter);
+ if (!match?.groups?.value) throw new Error(`Expected brightness filter, received "${filter}".`);
+ return Number(match.groups.value);
+}
+
+interface Rgb {
+ b: number;
+ g: number;
+ r: number;
+}
+
+function cssColorToRgb(color: string): Rgb {
+ const canvas = document.createElement('canvas');
+ canvas.width = 1;
+ canvas.height = 1;
+ const context = canvas.getContext('2d');
+ if (!context) throw new Error('Expected canvas context.');
+ context.fillStyle = color;
+ context.fillRect(0, 0, 1, 1);
+ const [r, g, b] = context.getImageData(0, 0, 1, 1).data;
+ if (r === undefined || g === undefined || b === undefined) {
+ throw new Error(`Expected RGB channels for "${color}".`);
+ }
+ return { b, g, r };
+}
+
+function applyBrightness(color: string, brightness: number): Rgb {
+ const rgb = cssColorToRgb(color);
+ return {
+ b: Math.min(rgb.b * brightness, 255),
+ g: Math.min(rgb.g * brightness, 255),
+ r: Math.min(rgb.r * brightness, 255),
+ };
+}
+
+function rgbToHex(color: Rgb) {
+ const channel = (value: number) => Math.round(value).toString(16).padStart(2, '0');
+ return `#${channel(color.r)}${channel(color.g)}${channel(color.b)}`;
+}
+
+function resolveSystemColor(color: string) {
+ if (!root) throw new Error('Expected theme root.');
+ const probe = root.appendChild(document.createElement('div'));
+ probe.style.color = color;
+ const value = getComputedStyle(probe).color;
+ probe.remove();
+ return value;
+}
+
+async function setEmulatedMedia(name?: string, value?: string) {
+ await cdp().send('Emulation.setEmulatedMedia', {
+ features: name === undefined || value === undefined ? [] : [{ name, value }],
+ });
+}
diff --git a/packages/@luke-ui/react/src/recipes/loading-skeleton.css.ts b/packages/@luke-ui/react/src/recipes/loading-skeleton.css.ts
index 5d7be593..f3eebe83 100644
--- a/packages/@luke-ui/react/src/recipes/loading-skeleton.css.ts
+++ b/packages/@luke-ui/react/src/recipes/loading-skeleton.css.ts
@@ -1,19 +1,21 @@
import type { StyleRule } from '@vanilla-extract/css';
-import { keyframes } from '@vanilla-extract/css';
+import { fallbackVar, keyframes } from '@vanilla-extract/css';
import { globalStyleInLayer, styleInLayer } from '../styles/layered-style.css.js';
-import { vars } from '../styles/vars.css.js';
+import { vars } from '../theme/contract.css.js';
/**
- * Generated name of the skeleton pulse animation. Pass to `useSynchronizeAnimations` so every mounted skeleton
- * pulses in lockstep.
+ * @internal
*/
export const skeletonAnimationName = keyframes({
- from: { filter: 'brightness(1)' },
- to: { filter: 'brightness(0.88)' },
+ '0%': { filter: 'brightness(1)' },
+ '10%': { filter: 'brightness(1)' },
+ '50%': { filter: 'brightness(0.88)' },
+ '60%': { filter: 'brightness(0.88)' },
+ '100%': { filter: 'brightness(1)' },
});
-/** Custom property that sets the corner radius of the skeleton overlay painted on wrapped children. */
-export const skeletonBorderRadiusVar = '--luke-ui-loading-skeleton-border-radius';
+/** @internal */
+export const skeletonRadiusVar = '--luke-loading-skeleton-radius';
// Forced onto every skeleton surface so an arbitrary wrapped component reads as a flat placeholder shape.
// `!important` is deliberate: cascade layers alone can't beat consumers' un-layered or inline styles, and the
@@ -21,7 +23,7 @@ export const skeletonBorderRadiusVar = '--luke-ui-loading-skeleton-border-radius
// admit the `!important` suffix in their type.
const surface = {
backgroundClip: 'border-box !important',
- backgroundColor: `${vars.backgroundColor.neutralBold} !important`,
+ backgroundColor: `${vars.color.loadingSkeleton} !important`,
backgroundImage: 'none !important',
border: 'none !important',
// Text spanning multiple lines keeps its radius on every line fragment.
@@ -34,15 +36,23 @@ const surface = {
userSelect: 'none !important' as 'none',
} as const satisfies StyleRule;
+const forcedColorsSurface = {
+ backgroundColor: 'CanvasText !important',
+ forcedColorAdjust: 'none !important' as 'none',
+} as const satisfies StyleRule;
+
// Not `!important`: reduced-motion overrides (below) and animation syncing must stay able to adjust it.
const pulse = {
animationDelay: '0.5s',
- animationDirection: 'alternate',
- animationDuration: vars.motion.duration.slow,
+ animationDuration: '2s',
animationIterationCount: 'infinite',
animationName: skeletonAnimationName,
- animationTimingFunction: vars.motion.easing.emphasized,
+ animationTimingFunction: vars.motion.easing.standard,
'@media': {
+ '(forced-colors: active)': {
+ ...forcedColorsSurface,
+ animationName: 'none',
+ },
// The global reduced-motion reset lives in the lowest layer, so it can't win against this rule.
'(prefers-reduced-motion: reduce)': {
animationName: 'none',
@@ -57,7 +67,7 @@ export const loadingSkeleton = styleInLayer('utilities', {
'&[data-skeleton-inline]': {
...surface,
...pulse,
- borderRadius: vars.borderRadius.small,
+ borderRadius: fallbackVar(`var(${skeletonRadiusVar})`, vars.radius.detail),
},
// Block mode: the wrapper is invisible; skeleton styles apply to its direct children.
'&:not([data-skeleton-inline])': {
@@ -73,12 +83,19 @@ globalStyleInLayer('utilities', `${loadingSkeleton}:not([data-skeleton-inline])
position: 'relative !important' as 'relative',
});
+globalStyleInLayer('utilities', `${loadingSkeleton}:not([data-skeleton-inline]) > * *`, {
+ '@media': {
+ '(forced-colors: active)': forcedColorsSurface,
+ },
+ ...surface,
+});
+
// A pseudo-element painted over the child covers visuals the forced styles can't reach (nested backgrounds,
// rounded corners); `inset: -1px` also covers the child's border box edges.
globalStyleInLayer('utilities', `${loadingSkeleton}:not([data-skeleton-inline]) > *::after`, {
...surface,
...pulse,
- borderRadius: `var(${skeletonBorderRadiusVar}, 0px)`,
+ borderRadius: `var(${skeletonRadiusVar}, 0px)`,
content: '""',
inset: '-1px',
position: 'absolute',
diff --git a/packages/@luke-ui/react/src/recipes/loading-spinner.browser.test.ts b/packages/@luke-ui/react/src/recipes/loading-spinner.browser.test.ts
new file mode 100644
index 00000000..8bb135e1
--- /dev/null
+++ b/packages/@luke-ui/react/src/recipes/loading-spinner.browser.test.ts
@@ -0,0 +1,53 @@
+import { afterEach, expect, test } from 'vite-plus/test';
+import { cdp } from 'vite-plus/test/context';
+import { indicator, spinnerState } from './loading-spinner.css.js';
+
+const mounted: Array = [];
+
+afterEach(async () => {
+ for (const element of mounted) element.remove();
+ mounted.length = 0;
+ await setEmulatedMedia();
+});
+
+test('indeterminate spinner uses the original rotation and rubber-band timing', () => {
+ const { ring, spinner } = mountSpinner();
+ const spinnerStyle = getComputedStyle(spinner);
+ const ringStyle = getComputedStyle(ring);
+
+ expect(spinnerStyle.animationDuration).toBe('1.2s');
+ expect(spinnerStyle.animationTimingFunction).toBe('linear');
+ expect(ringStyle.animationDuration).toBe('2s');
+ expect(ringStyle.animationTimingFunction).toBe('cubic-bezier(0.42, 0, 0.58, 1)');
+});
+
+for (const [name, value] of [
+ ['forced-colors', 'active'],
+ ['prefers-reduced-motion', 'reduce'],
+] as const) {
+ test(`${name} renders an indeterminate spinner as a static partial ring`, async () => {
+ await setEmulatedMedia(name, value);
+ const { ring, spinner } = mountSpinner();
+
+ expect(getComputedStyle(spinner).animationName).toBe('none');
+ expect(getComputedStyle(ring).animationName).toBe('none');
+ expect(getComputedStyle(ring).strokeDasharray).toBe('25px, 100px');
+ expect(getComputedStyle(ring).strokeDashoffset).toBe('0px');
+ });
+}
+
+function mountSpinner() {
+ const spinner = document.body.appendChild(document.createElement('div'));
+ spinner.className = spinnerState({ mode: 'indeterminate' });
+ const svg = spinner.appendChild(document.createElementNS('http://www.w3.org/2000/svg', 'svg'));
+ const ring = svg.appendChild(document.createElementNS('http://www.w3.org/2000/svg', 'circle'));
+ ring.setAttribute('class', indicator({ mode: 'indeterminate' }));
+ mounted.push(spinner);
+ return { ring, spinner };
+}
+
+async function setEmulatedMedia(name?: string, value?: string) {
+ await cdp().send('Emulation.setEmulatedMedia', {
+ features: name === undefined || value === undefined ? [] : [{ name, value }],
+ });
+}
diff --git a/packages/@luke-ui/react/src/recipes/loading-spinner.css.ts b/packages/@luke-ui/react/src/recipes/loading-spinner.css.ts
index 3dd76ef3..83d7c251 100644
--- a/packages/@luke-ui/react/src/recipes/loading-spinner.css.ts
+++ b/packages/@luke-ui/react/src/recipes/loading-spinner.css.ts
@@ -1,14 +1,22 @@
import { keyframes } from '@vanilla-extract/css';
import type { RecipeVariants } from '@vanilla-extract/recipes';
import { recipeInLayer, styleInLayer } from '../styles/layered-style.css.js';
-import { vars } from '../styles/vars.css.js';
-import { tokenKeys } from '../tokens/groups.js';
-import { tokens } from '../tokens/index.js';
+import { vars } from '../theme/contract.css.js';
import { iconSizeVariants } from './icon.css.js';
-const colorKeys = tokenKeys(tokens.foregroundColor);
+const rotationDuration = '1.2s';
+const rubberBandDuration = '2s';
+const rubberBandEasing = 'cubic-bezier(0.42, 0, 0.58, 1)';
-const colorVariants = Object.fromEntries(colorKeys.map((key) => [key, { color: vars.color[key] }]));
+const colorVariants = {
+ accent: { color: vars.color.intent.accent.text },
+ danger: { color: vars.color.intent.danger.text },
+ info: { color: vars.color.intent.info.text },
+ primary: { color: vars.color.text.primary },
+ secondary: { color: vars.color.text.secondary },
+ success: { color: vars.color.intent.success.text },
+ warning: { color: vars.color.intent.warning.text },
+} as const;
const base = styleInLayer('recipes', {
color: 'currentColor',
@@ -31,7 +39,7 @@ export const spinner = recipeInLayer('recipes', {
/** Variant type for the `LoadingSpinner` recipe. */
export type LoadingSpinnerVariants = RecipeVariants;
-/** Generated name of the spinner's rotation animation. Pass to `useSynchronizeAnimations` so every mounted spinner rotates in lockstep. */
+/** @internal */
export const spinAnimationName = keyframes({
to: { transform: 'rotate(360deg)' },
});
@@ -44,10 +52,14 @@ export const spinnerState = recipeInLayer('recipes', {
mode: {
determinate: {},
indeterminate: {
- animationDuration: vars.motion.duration.slow,
+ '@media': {
+ '(forced-colors: active)': { animationName: 'none' },
+ '(prefers-reduced-motion: reduce)': { animationName: 'none' },
+ },
+ animationDuration: rotationDuration,
animationIterationCount: 'infinite',
animationName: spinAnimationName,
- animationTimingFunction: vars.motion.easing.linear,
+ animationTimingFunction: 'linear',
},
},
},
@@ -62,7 +74,7 @@ export const svg = recipeInLayer('recipes', {
},
});
-/** Generated name of the spinner indicator's rubber-band animation. Pass to `useSynchronizeAnimations` so every mounted spinner pulses in lockstep. */
+/** @internal */
export const rubberBandAnimationName = keyframes({
'0%': { strokeDasharray: '2 100' },
'50%': { strokeDasharray: '65 100', strokeDashoffset: -20 },
@@ -79,15 +91,27 @@ export const indicator = recipeInLayer('recipes', {
variants: {
mode: {
determinate: {
- transitionDuration: vars.motion.duration.quick,
+ transitionDuration: vars.motion.duration.fast,
transitionProperty: 'stroke-dashoffset',
transitionTimingFunction: vars.motion.easing.exit,
},
indeterminate: {
- animationDuration: vars.motion.duration.slower,
+ '@media': {
+ '(forced-colors: active)': {
+ animationName: 'none',
+ strokeDasharray: '25 100',
+ strokeDashoffset: 0,
+ },
+ '(prefers-reduced-motion: reduce)': {
+ animationName: 'none',
+ strokeDasharray: '25 100',
+ strokeDashoffset: 0,
+ },
+ },
+ animationDuration: rubberBandDuration,
animationIterationCount: 'infinite',
animationName: rubberBandAnimationName,
- animationTimingFunction: vars.motion.easing.emphasized,
+ animationTimingFunction: rubberBandEasing,
},
},
},
diff --git a/packages/@luke-ui/react/src/theme/build-theme.test.ts b/packages/@luke-ui/react/src/theme/build-theme.test.ts
index 23fa0a3b..d737c6b2 100644
--- a/packages/@luke-ui/react/src/theme/build-theme.test.ts
+++ b/packages/@luke-ui/react/src/theme/build-theme.test.ts
@@ -116,6 +116,7 @@ describe('buildTheme output', () => {
it('uses the stable kebab-case variable names', () => {
expect(css).toContain('--luke-color-intent-danger-surface-solid-hover');
+ expect(css).toContain('--luke-color-loading-skeleton');
expect(css).toContain('--luke-color-surface-disabled');
expect(css).toContain('--luke-color-intent-accent-text-hover');
expect(css).toContain('--luke-depth-raised');
@@ -475,6 +476,19 @@ describe('bundled themes meet WCAG 2.2 AA', () => {
}
});
+describe('bundled loading skeleton surfaces', () => {
+ for (const foundation of [machinedEdgeFoundation, elmoFoundation]) {
+ it(`${foundation.name} keeps loading skeletons distinct from the canvas in both modes`, () => {
+ const blocks = splitBlocks(buildTheme(foundation));
+ for (const block of [blocks.baseLight, blocks.mediaDark]) {
+ const canvas = parseColor(extractValue(block, '--luke-color-surface-canvas'));
+ const skeleton = parseColor(extractValue(block, '--luke-color-loading-skeleton'));
+ expect(contrastRatio(skeleton, canvas)).toBeGreaterThanOrEqual(1.4);
+ }
+ });
+ }
+});
+
describe('bundled theme identity', () => {
it('exports class-name constants that match the emitted identity classes', () => {
expect(machinedEdgeThemeClassName).toBe('luke-ui-theme-machined-edge');
diff --git a/packages/@luke-ui/react/src/theme/build-theme.ts b/packages/@luke-ui/react/src/theme/build-theme.ts
index 231fdbe2..7f39974a 100644
--- a/packages/@luke-ui/react/src/theme/build-theme.ts
+++ b/packages/@luke-ui/react/src/theme/build-theme.ts
@@ -89,8 +89,11 @@ type ColorMode = 'light' | 'dark';
const THEME_NAME_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
const TEXT_RATIO = 4.5;
const UI_RATIO = 3;
-// Solve slightly past the required ratio so 4-decimal OKLCH emission cannot round a passing pair
-// below the WCAG threshold.
+// Skeletons are decorative, so this is a perceptual distinction target rather than a WCAG
+// requirement. Dark mode needs more headroom because the brightness pulse moves toward the canvas.
+const LOADING_SKELETON_RATIOS = { dark: 1.75, light: 1.4 } as const;
+// Solve slightly past the requested ratio so 4-decimal OKLCH emission cannot round a passing pair
+// below its target.
const RATIO_HEADROOM = 0.05;
const INTENT_NAMES = ['neutral', 'accent', 'info', 'success', 'warning', 'danger'] as const;
@@ -264,6 +267,15 @@ function buildModeColors(
'color.surface.floating': surfaces.floating,
'color.surface.overlay': surfaces.overlay,
};
+ colors['color.loadingSkeleton'] = solveLightness({
+ backgrounds: [canvas],
+ chroma: Math.min(neutral.c, 0.01),
+ hue: neutral.h,
+ mode,
+ ratio: LOADING_SKELETON_RATIOS[mode],
+ startLightness: canvas.l,
+ window: isLight ? [0, canvas.l] : [canvas.l, 1],
+ });
colors['color.text.primary'] = solveLightness({
backgrounds: allSurfaces,
diff --git a/packages/@luke-ui/react/src/theme/contract.ts b/packages/@luke-ui/react/src/theme/contract.ts
index 51731171..a6d4c38d 100644
--- a/packages/@luke-ui/react/src/theme/contract.ts
+++ b/packages/@luke-ui/react/src/theme/contract.ts
@@ -15,6 +15,7 @@ export const themeContractTree = {
color: {
surface: { canvas: null, resting: null, recessed: null, floating: null, overlay: null },
surfaceDisabled: null,
+ loadingSkeleton: null,
text: { primary: null, secondary: null },
textDisabled: null,
border: { decorative: null, control: null, focus: null },
diff --git a/packages/@luke-ui/react/src/use-synchronize-animations/use-synchronize-animations.ts b/packages/@luke-ui/react/src/use-synchronize-animations/use-synchronize-animations.ts
index f3ce2b8c..5d3f4235 100644
--- a/packages/@luke-ui/react/src/use-synchronize-animations/use-synchronize-animations.ts
+++ b/packages/@luke-ui/react/src/use-synchronize-animations/use-synchronize-animations.ts
@@ -7,8 +7,8 @@ const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffec
* Synchronises the CSS animation identified by `animationName` across every element currently playing it, so they
* all pulse in lockstep rather than each starting from their own offset.
*
- * Pass the animation name string (e.g. `skeletonAnimationName`), or a falsy value to skip syncing (e.g. when the
- * animated element isn't rendered).
+ * Pass an animation name string, or a falsy value to skip syncing when the animated element isn't
+ * rendered.
*/
export function useSynchronizeAnimations(animationName: string | null | undefined): void {
useIsomorphicLayoutEffect(() => {