Skip to content

Commit 81aaf0b

Browse files
feat: add web support via CSS transitions
Implement EaseView.web.tsx that uses CSS transitions for timing animations, CSS @Keyframes for loop animations, and a timing approximation for springs. React Native's .web.tsx resolution automatically picks this up on web.
1 parent 6a18531 commit 81aaf0b

1 file changed

Lines changed: 277 additions & 0 deletions

File tree

src/EaseView.web.tsx

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
/// <reference lib="dom" />
2+
import React, { useEffect, useRef, useState, useCallback } from 'react';
3+
import type {
4+
AnimateProps,
5+
CubicBezier,
6+
Transition,
7+
TransitionEndEvent,
8+
TransformOrigin,
9+
} from './types';
10+
11+
/** Identity values used as defaults for animate/initialAnimate. */
12+
const IDENTITY: Required<Omit<AnimateProps, 'scale' | 'backgroundColor'>> = {
13+
opacity: 1,
14+
translateX: 0,
15+
translateY: 0,
16+
scaleX: 1,
17+
scaleY: 1,
18+
rotate: 0,
19+
rotateX: 0,
20+
rotateY: 0,
21+
borderRadius: 0,
22+
};
23+
24+
/** Preset easing curves as cubic bezier control points. */
25+
const EASING_PRESETS: Record<string, CubicBezier> = {
26+
linear: [0, 0, 1, 1],
27+
easeIn: [0.42, 0, 1, 1],
28+
easeOut: [0, 0, 0.58, 1],
29+
easeInOut: [0.42, 0, 0.58, 1],
30+
};
31+
32+
export type EaseViewProps = {
33+
animate?: AnimateProps;
34+
initialAnimate?: AnimateProps;
35+
transition?: Transition;
36+
onTransitionEnd?: (event: TransitionEndEvent) => void;
37+
/** No-op on web. */
38+
useHardwareLayer?: boolean;
39+
transformOrigin?: TransformOrigin;
40+
style?: React.CSSProperties;
41+
children?: React.ReactNode;
42+
} & Omit<React.HTMLAttributes<HTMLDivElement>, 'style'>;
43+
44+
function resolveAnimateValues(props: AnimateProps | undefined): Required<
45+
Omit<AnimateProps, 'scale' | 'backgroundColor'>
46+
> & {
47+
backgroundColor?: string;
48+
} {
49+
return {
50+
...IDENTITY,
51+
...props,
52+
scaleX: props?.scaleX ?? props?.scale ?? IDENTITY.scaleX,
53+
scaleY: props?.scaleY ?? props?.scale ?? IDENTITY.scaleY,
54+
rotateX: props?.rotateX ?? IDENTITY.rotateX,
55+
rotateY: props?.rotateY ?? IDENTITY.rotateY,
56+
backgroundColor: props?.backgroundColor as string | undefined,
57+
};
58+
}
59+
60+
function buildTransform(vals: ReturnType<typeof resolveAnimateValues>): string {
61+
const parts: string[] = [];
62+
if (vals.translateX !== 0 || vals.translateY !== 0) {
63+
parts.push(`translate(${vals.translateX}px, ${vals.translateY}px)`);
64+
}
65+
if (vals.scaleX !== 1 || vals.scaleY !== 1) {
66+
parts.push(
67+
vals.scaleX === vals.scaleY
68+
? `scale(${vals.scaleX})`
69+
: `scale(${vals.scaleX}, ${vals.scaleY})`,
70+
);
71+
}
72+
if (vals.rotate !== 0) {
73+
parts.push(`rotate(${vals.rotate}deg)`);
74+
}
75+
if (vals.rotateX !== 0) {
76+
parts.push(`rotateX(${vals.rotateX}deg)`);
77+
}
78+
if (vals.rotateY !== 0) {
79+
parts.push(`rotateY(${vals.rotateY}deg)`);
80+
}
81+
return parts.length > 0 ? parts.join(' ') : 'none';
82+
}
83+
84+
function resolveEasing(transition: Transition | undefined): string {
85+
if (!transition || transition.type !== 'timing') {
86+
return 'cubic-bezier(0.42, 0, 0.58, 1)';
87+
}
88+
const easing = transition.easing ?? 'easeInOut';
89+
const bezier: CubicBezier = Array.isArray(easing)
90+
? easing
91+
: EASING_PRESETS[easing]!;
92+
return `cubic-bezier(${bezier[0]}, ${bezier[1]}, ${bezier[2]}, ${bezier[3]})`;
93+
}
94+
95+
function resolveDuration(transition: Transition | undefined): number {
96+
if (!transition) return 300;
97+
if (transition.type === 'timing') return transition.duration ?? 300;
98+
if (transition.type === 'none') return 0;
99+
// Spring type: approximate duration from damping/stiffness/mass.
100+
// A critically-damped spring settles in ~4-5 time constants.
101+
// tau = 2 * mass / damping, settle ~ 4 * tau
102+
const damping = transition.damping ?? 15;
103+
const mass = transition.mass ?? 1;
104+
const tau = (2 * mass) / damping;
105+
return Math.round(tau * 4 * 1000);
106+
}
107+
108+
/** CSS transition properties that we animate. */
109+
const TRANSITION_PROPS = [
110+
'opacity',
111+
'transform',
112+
'border-radius',
113+
'background-color',
114+
];
115+
116+
/** Counter for unique keyframe names. */
117+
let keyframeCounter = 0;
118+
119+
export function EaseView({
120+
animate,
121+
initialAnimate,
122+
transition,
123+
onTransitionEnd,
124+
useHardwareLayer: _useHardwareLayer,
125+
transformOrigin,
126+
style,
127+
children,
128+
...rest
129+
}: EaseViewProps) {
130+
const resolved = resolveAnimateValues(animate);
131+
const hasInitial = initialAnimate != null;
132+
const [mounted, setMounted] = useState(!hasInitial);
133+
const divRef = useRef<HTMLDivElement>(null);
134+
const animationNameRef = useRef<string | null>(null);
135+
136+
// For initialAnimate: render initial values first, then animate on mount.
137+
useEffect(() => {
138+
if (hasInitial) {
139+
// Force a layout read to flush initial styles before enabling transitions.
140+
divRef.current?.getBoundingClientRect();
141+
setMounted(true);
142+
}
143+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
144+
145+
// Handle transitionend event.
146+
const handleTransitionEnd = useCallback(
147+
(e: React.TransitionEvent<HTMLDivElement>) => {
148+
// Only fire for our own transitions, not children bubbling up.
149+
if (e.target !== e.currentTarget) return;
150+
// Fire once per batch — use opacity as the sentinel property.
151+
if (e.propertyName !== 'opacity' && e.propertyName !== 'transform')
152+
return;
153+
onTransitionEnd?.({ finished: true });
154+
},
155+
[onTransitionEnd],
156+
);
157+
158+
// Determine which values to render.
159+
const displayValues =
160+
!mounted && hasInitial ? resolveAnimateValues(initialAnimate) : resolved;
161+
162+
const duration = resolveDuration(transition);
163+
const easing = resolveEasing(transition);
164+
165+
// Build computed styles.
166+
const transformStr = buildTransform(displayValues);
167+
const originX = ((transformOrigin?.x ?? 0.5) * 100).toFixed(1);
168+
const originY = ((transformOrigin?.y ?? 0.5) * 100).toFixed(1);
169+
170+
const transitionType = transition?.type ?? 'timing';
171+
const loopMode = transition?.type === 'timing' ? transition.loop : undefined;
172+
173+
// Build CSS transition string.
174+
const transitionCss =
175+
transitionType === 'none' || (!mounted && hasInitial)
176+
? 'none'
177+
: TRANSITION_PROPS.map((prop) => `${prop} ${duration}ms ${easing}`).join(
178+
', ',
179+
);
180+
181+
// Handle loop animations via CSS @keyframes.
182+
useEffect(() => {
183+
const el = divRef.current;
184+
if (!loopMode || !el) {
185+
// Clean up any existing animation.
186+
if (animationNameRef.current && el) {
187+
el.style.animation = '';
188+
animationNameRef.current = null;
189+
}
190+
return;
191+
}
192+
193+
const fromValues = initialAnimate
194+
? resolveAnimateValues(initialAnimate)
195+
: resolveAnimateValues(undefined);
196+
const toValues = resolveAnimateValues(animate);
197+
198+
const fromTransform = buildTransform(fromValues);
199+
const toTransform = buildTransform(toValues);
200+
201+
const name = `ease-loop-${++keyframeCounter}`;
202+
animationNameRef.current = name;
203+
204+
const fromBlock = [
205+
`opacity: ${fromValues.opacity}`,
206+
`transform: ${fromTransform}`,
207+
`border-radius: ${fromValues.borderRadius}px`,
208+
fromValues.backgroundColor
209+
? `background-color: ${fromValues.backgroundColor}`
210+
: '',
211+
]
212+
.filter(Boolean)
213+
.join('; ');
214+
215+
const toBlock = [
216+
`opacity: ${toValues.opacity}`,
217+
`transform: ${toTransform}`,
218+
`border-radius: ${toValues.borderRadius}px`,
219+
toValues.backgroundColor
220+
? `background-color: ${toValues.backgroundColor}`
221+
: '',
222+
]
223+
.filter(Boolean)
224+
.join('; ');
225+
226+
const keyframes = `@keyframes ${name} { from { ${fromBlock} } to { ${toBlock} } }`;
227+
228+
const styleEl = document.createElement('style');
229+
styleEl.textContent = keyframes;
230+
document.head.appendChild(styleEl);
231+
232+
const direction = loopMode === 'reverse' ? 'alternate' : 'normal';
233+
el.style.animation = `${name} ${duration}ms ${easing} infinite ${direction}`;
234+
235+
return () => {
236+
styleEl.remove();
237+
el.style.animation = '';
238+
animationNameRef.current = null;
239+
};
240+
}, [loopMode, animate, initialAnimate, duration, easing]);
241+
242+
const computedStyle: React.CSSProperties = {
243+
...style,
244+
opacity: displayValues.opacity,
245+
transform: transformStr,
246+
transformOrigin: `${originX}% ${originY}%`,
247+
borderRadius:
248+
displayValues.borderRadius > 0
249+
? displayValues.borderRadius
250+
: style?.borderRadius,
251+
backgroundColor: displayValues.backgroundColor ?? style?.backgroundColor,
252+
transition: loopMode ? 'none' : transitionCss,
253+
// Spring approximation: use the same CSS transition with estimated duration.
254+
// CSS does not natively support spring physics, so this is a best-effort
255+
// timing approximation using an ease-out curve for a spring-like feel.
256+
...(transitionType === 'spring' && !loopMode
257+
? {
258+
transition: TRANSITION_PROPS.map(
259+
(prop) =>
260+
`${prop} ${duration}ms cubic-bezier(0.25, 0.46, 0.45, 0.94)`,
261+
).join(', '),
262+
}
263+
: {}),
264+
willChange: 'transform, opacity',
265+
};
266+
267+
return (
268+
<div
269+
ref={divRef}
270+
style={computedStyle}
271+
onTransitionEnd={onTransitionEnd ? handleTransitionEnd : undefined}
272+
{...rest}
273+
>
274+
{children}
275+
</div>
276+
);
277+
}

0 commit comments

Comments
 (0)