Skip to content

Commit f3fed42

Browse files
committed
perf: optimize logo animation and fix Safari rendering issues
PlatonicLogo: - Cache size-dependent style strings (translate/rotate/stagger) by solidIndex:size, eliminating ~80 toFixed() allocations per render - Precompute active face indices at module load - Move face transitions to inline styles, only active during morphs — prevents Safari from re-evaluating transition timing each rAF frame - Snap projected slot U-vector to nearest face edge, fixing in-plane tilt on cube faces - Per-face depth bias (0.01px outward push) breaks z-degeneracy for edge-on faces without backface-visibility:hidden - Clear resumeTimerRef on unmount, mark pointermove passive CelebrationEffect: - Bake trail translate positions into keyframe stops instead of animating via @Property --trail-progress (Safari doesn't reliably animate registered custom properties in translate calc expressions) - Quadratic ease-out arc sampled at 8 stops for natural deceleration - Reduce particle text-shadow to single 10px layer - Use window.innerWidth/Height instead of getBoundingClientRect
1 parent e1ac496 commit f3fed42

File tree

3 files changed

+141
-61
lines changed

3 files changed

+141
-61
lines changed

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,5 +52,5 @@ Before editing anything that touches styled-components APIs (`createTheme`, `The
5252
- OKLCH hue 0° is pink/magenta, not red. Warmer = higher hue toward orange. Read `logoPalette.ts` for the offset that places red at step 0.
5353
- `mix-blend-mode` and `filter` on children of `preserve-3d` elements flattens 3D. Use alpha in background colors or `color-mix` instead.
5454
- Blog posts are assembled dynamically from MDX files at build time by `utils/blog.server.ts`. No JSON index to maintain — just create the MDX file with `export const meta`.
55-
- `PlatonicLogo.tsx` faces must NOT use `backface-visibility: hidden`. Per-face axis-angle interpolation during morph transitions can briefly flip a face normal and cull mid-animation. `transform-style: preserve-3d` z-sorts back faces naturally.
55+
- `PlatonicLogo.tsx` faces must NOT use `backface-visibility: hidden`. Per-face axis-angle interpolation during morph transitions can briefly flip a face normal and cull mid-animation. `transform-style: preserve-3d` z-sorts back faces naturally. Per-face depth bias (tiny outward push along each face's normal) breaks z-degeneracy for edge-on faces without culling.
5656
- `CelebrationEffect.tsx` particles are `React.memo`'d; `onAnimationEnd` is a stable `useCallback` that reads `fwId`/`particleId` from `data-*` attributes. Don't close over IDs in per-item arrow functions — it defeats memoization on the particle list.

components/CelebrationEffect.tsx

Lines changed: 42 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,6 @@ const PropertyRegistrations = createGlobalStyle`
8080
inherits: false;
8181
initial-value: 0;
8282
}
83-
84-
@property --trail-progress {
85-
syntax: '<number>';
86-
inherits: false;
87-
initial-value: 0;
88-
}
8983
`;
9084

9185
// ---------------------------------------------------------------------------
@@ -105,17 +99,50 @@ const burstAnim = keyframes`
10599
// Flash peak at 92% matches the late-ignition point (particle delay =
106100
// duration - 0.1). Shadow count stays uniform across stops so text-shadow
107101
// interpolates smoothly instead of flickering between shadow counts.
102+
//
103+
// translate is baked into each keyframe stop using calc() with static
104+
// per-instance custom properties (--start-x/y, --end-x/y) instead of
105+
// animating via @property --trail-progress, which Safari doesn't reliably
106+
// animate inside translate calc expressions.
107+
// X: linear interpolation at each stop (p).
108+
// Y: quadratic ease-out 2p-p² evaluated at each stop — decelerating rise.
109+
// Y uses quadratic ease-out: f(p) = 2p - p². Evaluated at each stop:
110+
// p=0: 0, p=0.2: 0.36, p=0.4: 0.64, p=0.6: 0.84, p=0.8: 0.96,
111+
// p=0.86: 0.9804, p=0.92: 0.9936, p=1: 1
108112
const trailAnim = keyframes`
109113
0% {
110-
--trail-progress: 0;
114+
translate: var(--start-x) var(--start-y);
111115
opacity: 0.3;
112116
text-shadow:
113117
0 0 6px var(--particle-color),
114118
0 0 16px var(--particle-color),
115119
0 0 0 transparent;
116120
scale: 1;
117121
}
122+
20% {
123+
translate:
124+
calc(var(--start-x) + (var(--end-x) - var(--start-x)) * 0.2)
125+
calc(var(--start-y) + (var(--end-y) - var(--start-y)) * 0.36);
126+
}
127+
40% {
128+
translate:
129+
calc(var(--start-x) + (var(--end-x) - var(--start-x)) * 0.4)
130+
calc(var(--start-y) + (var(--end-y) - var(--start-y)) * 0.64);
131+
}
132+
60% {
133+
translate:
134+
calc(var(--start-x) + (var(--end-x) - var(--start-x)) * 0.6)
135+
calc(var(--start-y) + (var(--end-y) - var(--start-y)) * 0.84);
136+
}
137+
80% {
138+
translate:
139+
calc(var(--start-x) + (var(--end-x) - var(--start-x)) * 0.8)
140+
calc(var(--start-y) + (var(--end-y) - var(--start-y)) * 0.96);
141+
}
118142
86% {
143+
translate:
144+
calc(var(--start-x) + (var(--end-x) - var(--start-x)) * 0.86)
145+
calc(var(--start-y) + (var(--end-y) - var(--start-y)) * 0.9804);
119146
opacity: 1;
120147
text-shadow:
121148
0 0 10px var(--particle-color),
@@ -124,6 +151,9 @@ const trailAnim = keyframes`
124151
scale: 1.05;
125152
}
126153
92% {
154+
translate:
155+
calc(var(--start-x) + (var(--end-x) - var(--start-x)) * 0.92)
156+
calc(var(--start-y) + (var(--end-y) - var(--start-y)) * 0.9936);
127157
opacity: 1;
128158
text-shadow:
129159
0 0 20px var(--particle-color),
@@ -132,7 +162,7 @@ const trailAnim = keyframes`
132162
scale: 1.3;
133163
}
134164
100% {
135-
--trail-progress: 1;
165+
translate: var(--end-x) var(--end-y);
136166
opacity: 0;
137167
text-shadow:
138168
0 0 4px var(--particle-color),
@@ -185,13 +215,6 @@ const Trail = styled.span.attrs<{
185215
left: 0;
186216
font-family: monospace;
187217
color: var(--particle-color);
188-
/* X: linear interpolation. Y: quadratic ease-out (2p - p²) — decelerating
189-
rise, zero velocity at apex, exactly how gravity slows a launched shell. */
190-
translate: calc(var(--start-x) + (var(--end-x) - var(--start-x)) * var(--trail-progress))
191-
calc(
192-
var(--start-y) + (var(--end-y) - var(--start-y)) *
193-
(2 * var(--trail-progress) - var(--trail-progress) * var(--trail-progress))
194-
);
195218
animation: ${trailAnim} linear forwards;
196219
will-change: translate, opacity, scale;
197220
`;
@@ -239,10 +262,7 @@ const particleBase = css`
239262

240263
const ParticleCharBase = styled.span.attrs<ParticleAttrs>(particleAttrs)`
241264
${particleBase}
242-
text-shadow:
243-
0 0 8px var(--particle-color),
244-
0 0 24px var(--particle-color),
245-
0 0 60px var(--particle-color);
265+
text-shadow: 0 0 10px var(--particle-color);
246266
`;
247267

248268
const ParticleChar = React.memo(ParticleCharBase);
@@ -288,14 +308,12 @@ export default function CelebrationEffect() {
288308
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
289309

290310
function spawnFirework() {
291-
const el = overlayRef.current;
292-
if (!el) return;
311+
if (!overlayRef.current) return;
293312

294313
const tier = getTier();
295314
const cfg = TIER_CONFIG[tier];
296-
const rect = el.getBoundingClientRect();
297-
const w = rect.width;
298-
const h = rect.height;
315+
const w = window.innerWidth;
316+
const h = window.innerHeight;
299317

300318
const burstX = rand(w * 0.15, w * 0.85);
301319
// Launch position: offset sideways from the burst point so the shell

components/LogoConcepts/PlatonicLogo.tsx

Lines changed: 98 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -426,12 +426,32 @@ function computeSingleFace(solid: (typeof SOLIDS)[number], fi: number, divIdx: n
426426
let normal = normalize(cross(sub(verts[1], verts[0]), sub(verts[2], verts[0])));
427427
if (dot3(center, normal) < 0) normal = [-normal[0], -normal[1], -normal[2]];
428428

429-
// Align in-plane rotation to slot's U vector (projected onto this face's plane)
429+
// Align in-plane rotation to the face edge nearest to the slot's U vector.
430+
// First project slot U onto the face plane, then snap to the closest edge
431+
// direction (respecting face symmetry). This prevents faces with high
432+
// symmetry (squares, pentagons) from appearing tilted at rest.
430433
const d = dot3(slot.u, normal);
431434
const projected: Vec3 = [slot.u[0] - d * normal[0], slot.u[1] - d * normal[1], slot.u[2] - d * normal[2]];
432435
const pLen = Math.hypot(...projected);
433-
const u: Vec3 =
434-
pLen > 0.001 ? [projected[0] / pLen, projected[1] / pLen, projected[2] / pLen] : normalize(sub(verts[1], verts[0]));
436+
let u: Vec3;
437+
if (pLen > 0.001) {
438+
const projU: Vec3 = [projected[0] / pLen, projected[1] / pLen, projected[2] / pLen];
439+
// Snap to nearest face edge direction
440+
let bestDot = -2;
441+
let bestEdge: Vec3 = projU;
442+
for (let ei = 0; ei < n; ei++) {
443+
const edge = normalize(sub(verts[(ei + 1) % n], verts[ei]));
444+
const alignment = Math.abs(dot3(projU, edge));
445+
if (alignment > bestDot) {
446+
bestDot = alignment;
447+
// Preserve sign: if projected U opposes the edge, flip
448+
bestEdge = dot3(projU, edge) >= 0 ? edge : [-edge[0], -edge[1], -edge[2]];
449+
}
450+
}
451+
u = bestEdge;
452+
} else {
453+
u = normalize(sub(verts[1], verts[0]));
454+
}
435455
const v: Vec3 = normalize(cross(normal, u));
436456

437457
// Project onto face's OWN plane (correct geometry)
@@ -500,6 +520,46 @@ function computeConfigs(solidIdx: number): FaceConfig[] {
500520

501521
const ALL_CONFIGS = SOLIDS.map((_, i) => computeConfigs(i));
502522

523+
// Pre-computed active face indices and action slot counts per solid
524+
const ALL_ACTIVE_INDICES: number[][] = ALL_CONFIGS.map(configs =>
525+
configs.reduce<number[]>((acc, c, i) => {
526+
if (c.active) acc.push(i);
527+
return acc;
528+
}, [])
529+
);
530+
531+
// Pre-computed size-dependent style strings (translate, rotate, stagger, iconSize)
532+
// keyed by solidIndex:size. Avoids ~80 toFixed() calls + string allocations per render.
533+
const solidStyleCache = new Map<string, { translate: string; rotate: string; stagger: string; iconSize: string }[]>();
534+
535+
// Per-face depth bias: push each face outward along its normal by a tiny
536+
// unique amount (i * DEPTH_BIAS_PX). Breaks z-degeneracy for the painter's
537+
// algorithm when two faces are edge-on, preventing flicker without needing
538+
// backface-visibility:hidden. Face centers point along the outward normal
539+
// for platonic solids, so scaling the translation achieves the push.
540+
const DEPTH_BIAS_PX = 0.01;
541+
542+
function getSolidStyles(solidIndex: number, size: number) {
543+
const key = `${solidIndex}:${size}`;
544+
const cached = solidStyleCache.get(key);
545+
if (cached) return cached;
546+
const s = size * SCALE;
547+
const configs = ALL_CONFIGS[solidIndex];
548+
const styles = configs.map((face, i) => {
549+
// Normalize the face center to get outward direction, then add depth bias
550+
const cLen = Math.hypot(face.tx, face.ty, face.tz);
551+
const bias = cLen > 0.001 ? (i * DEPTH_BIAS_PX) / cLen : 0;
552+
return {
553+
translate: `${(face.tx * (s + bias)).toFixed(3)}px ${(face.ty * (s + bias)).toFixed(3)}px ${(face.tz * (s + bias)).toFixed(3)}px`,
554+
rotate: `${face.ax.toFixed(6)} ${face.ay.toFixed(6)} ${face.az.toFixed(6)} ${face.angle.toFixed(3)}deg`,
555+
stagger: `${((i / (MAX_FACES - 1)) * STAGGER_MS * 0.3).toFixed(2)}ms`,
556+
iconSize: `${(face.radius * size * 0.5).toFixed(3)}px`,
557+
};
558+
});
559+
solidStyleCache.set(key, styles);
560+
return styles;
561+
}
562+
503563
// ---------------------------------------------------------------------------
504564
// Styled components
505565
// ---------------------------------------------------------------------------
@@ -543,7 +603,7 @@ type TransitionPhase = 'emerging' | 'persisting' | 'collapsing' | 'static';
543603

544604
const EASE = 'cubic-bezier(0.28, 0.11, 0.32, 1)';
545605

546-
const Face = styled.div<{ $phase: TransitionPhase; $active: boolean; $morphing: boolean }>`
606+
const Face = styled.div<{ $active: boolean }>`
547607
position: absolute;
548608
inset: 0;
549609
/* No backface-visibility: hidden — during solid-to-solid morph transitions
@@ -552,18 +612,6 @@ const Face = styled.div<{ $phase: TransitionPhase; $active: boolean; $morphing:
552612
via preserve-3d occludes back faces behind opaque front faces naturally. */
553613
pointer-events: ${p => (p.$active ? 'auto' : 'none')};
554614
opacity: ${p => (p.$active ? 1 : 0)};
555-
scale: ${p => (p.$morphing ? MORPH_SCALE : 1)};
556-
transition: ${p => {
557-
const base = p.$phase === 'persisting' ? STAGGER_MS * 0.6 : 0;
558-
return [
559-
`clip-path ${MORPH_MS}ms ${EASE} calc(${base}ms + var(--stagger))`,
560-
`translate ${MORPH_MS}ms ${EASE} calc(${base}ms + var(--stagger))`,
561-
`rotate ${MORPH_MS}ms ${EASE} calc(${base}ms + var(--stagger))`,
562-
`opacity ${MORPH_MS * 0.6}ms ${EASE} calc(${base}ms + var(--stagger))`,
563-
`background 600ms ease-in-out`,
564-
`scale ${p.$morphing ? MORPH_MS * 0.3 : MORPH_MS * 0.8}ms ${EASE}`,
565-
].join(', ');
566-
}};
567615
568616
&::after {
569617
content: '';
@@ -855,7 +903,6 @@ export default function PlatonicLogo({
855903
const prevConfigsRef = useRef(configs);
856904
const [morphing, setMorphing] = useState(false);
857905
const hasMountedRef = useRef(false);
858-
const s = size * SCALE;
859906

860907
useEffect(() => {
861908
if (!hasMountedRef.current) {
@@ -879,9 +926,9 @@ export default function PlatonicLogo({
879926
}, []);
880927

881928
// --- Face action handler (ref so pointer effect always sees latest state) ---
929+
const activeIndices = ALL_ACTIVE_INDICES[solidIndex];
882930
const handleFaceAction = (actionIdx: number) => {
883-
const activeFaces = configs.filter(f => f.active);
884-
const actions = getActionsForFaceCount(activeFaces.length, solidIndex, prefs);
931+
const actions = getActionsForFaceCount(activeIndices.length, solidIndex, prefs);
885932
const action = actions[actionIdx];
886933
if (!action) return;
887934

@@ -1003,33 +1050,31 @@ export default function PlatonicLogo({
10031050
};
10041051

10051052
scene.addEventListener('pointerdown', onPointerDown);
1006-
scene.addEventListener('pointermove', onPointerMove);
1053+
scene.addEventListener('pointermove', onPointerMove, { passive: true });
10071054
scene.addEventListener('pointerup', onPointerUp);
10081055
scene.addEventListener('pointercancel', onPointerUp);
10091056
return () => {
10101057
scene.removeEventListener('pointerdown', onPointerDown);
10111058
scene.removeEventListener('pointermove', onPointerMove);
10121059
scene.removeEventListener('pointerup', onPointerUp);
10131060
scene.removeEventListener('pointercancel', onPointerUp);
1061+
if (resumeTimerRef.current) clearTimeout(resumeTimerRef.current);
10141062
};
10151063
}, []);
10161064

10171065
useEffect(() => {
10181066
prevConfigsRef.current = configs;
10191067
}, [configs]);
10201068

1021-
// Build action assignments for active faces
1022-
const activeFaces = configs.filter(f => f.active);
1023-
const actions = getActionsForFaceCount(activeFaces.length, solidIndex, prefs);
1024-
let actionAssignIdx = 0;
1069+
// Build action assignments for active faces (using precomputed active indices)
1070+
const actions = getActionsForFaceCount(activeIndices.length, solidIndex, prefs);
10251071
const faceActionMap = new Map<number, { action: FaceAction; idx: number }>();
1026-
for (let i = 0; i < configs.length; i++) {
1027-
if (configs[i].active && actionAssignIdx < actions.length) {
1028-
faceActionMap.set(i, { action: actions[actionAssignIdx], idx: actionAssignIdx });
1029-
actionAssignIdx++;
1030-
}
1072+
for (let ai = 0; ai < Math.min(activeIndices.length, actions.length); ai++) {
1073+
faceActionMap.set(activeIndices[ai], { action: actions[ai], idx: ai });
10311074
}
10321075

1076+
const slotStyles = getSolidStyles(solidIndex, size);
1077+
10331078
return (
10341079
<Wrapper className={className}>
10351080
<Scene style={{ width: size, height: size }}>
@@ -1044,15 +1089,30 @@ export default function PlatonicLogo({
10441089
: wasActive && !face.active
10451090
? 'collapsing'
10461091
: 'static';
1047-
const stagger = (i / (MAX_FACES - 1)) * STAGGER_MS * 0.3;
10481092
const mapped = faceActionMap.get(i);
1093+
const cachedStyle = slotStyles[i];
1094+
1095+
// Geometry transitions activate when faces are emerging/persisting/
1096+
// collapsing (phase !== 'static') OR during the scale bounce
1097+
// (morphing). At rest, only background + scale transitions remain,
1098+
// preventing Safari from re-evaluating timing each rAF frame.
1099+
const isTransitioning = phase !== 'static' || morphing;
1100+
const base = phase === 'persisting' ? STAGGER_MS * 0.6 : 0;
1101+
const transition = isTransitioning
1102+
? [
1103+
`clip-path ${MORPH_MS}ms ${EASE} calc(${base}ms + var(--stagger))`,
1104+
`translate ${MORPH_MS}ms ${EASE} calc(${base}ms + var(--stagger))`,
1105+
`rotate ${MORPH_MS}ms ${EASE} calc(${base}ms + var(--stagger))`,
1106+
`opacity ${MORPH_MS * 0.6}ms ${EASE} calc(${base}ms + var(--stagger))`,
1107+
`background 600ms ease-in-out`,
1108+
`scale ${morphing ? MORPH_MS * 0.3 : MORPH_MS * 0.8}ms ${EASE}`,
1109+
].join(', ')
1110+
: 'background 600ms ease-in-out';
10491111

10501112
return (
10511113
<Face
10521114
key={i}
1053-
$phase={phase}
10541115
$active={face.active}
1055-
$morphing={morphing}
10561116
data-face-action={mapped ? mapped.idx : undefined}
10571117
style={
10581118
{
@@ -1062,15 +1122,17 @@ export default function PlatonicLogo({
10621122
? `color-mix(in oklch, ${theme.palette[face.paletteStep]}, transparent 40%)`
10631123
: theme.palette[face.paletteStep]
10641124
: 'transparent',
1065-
translate: `${(face.tx * s).toFixed(3)}px ${(face.ty * s).toFixed(3)}px ${(face.tz * s).toFixed(3)}px`,
1066-
rotate: `${face.ax.toFixed(6)} ${face.ay.toFixed(6)} ${face.az.toFixed(6)} ${face.angle.toFixed(3)}deg`,
1067-
'--stagger': `${stagger}ms`,
1125+
translate: cachedStyle.translate,
1126+
rotate: cachedStyle.rotate,
1127+
scale: morphing ? MORPH_SCALE : 1,
1128+
transition,
1129+
'--stagger': cachedStyle.stagger,
10681130
'--face-opaque': face.active ? theme.palette[face.paletteStep] : 'transparent',
10691131
} as React.CSSProperties
10701132
}
10711133
>
10721134
{mapped && face.active && size >= 100 && (
1073-
<FaceIcon style={{ width: `${face.radius * size * 0.5}px`, height: `${face.radius * size * 0.5}px` }}>
1135+
<FaceIcon style={{ width: cachedStyle.iconSize, height: cachedStyle.iconSize }}>
10741136
{mapped.action.icon}
10751137
</FaceIcon>
10761138
)}

0 commit comments

Comments
 (0)