feat(splash): a colour burst as the mark hands over to the page

Subtle and half-transparent, per the brief. Four blobs, one per monogram cell,
each in that letter's own colour, thrown outward from where the letter was: the
mark comes apart into its own palette rather than flashing something unrelated.
The peak opacity is 0.42 light / 0.5 dark and the gradients never reach full
alpha, so it stays a wash rather than a curtain.

Only transform and opacity animate. Both are compositor properties, so this
does not repaint per frame. That is deliberate: animating a filter is what
pegged a core on the matrix animation, because a filter change re-renders the
whole filter graph every frame. Soft edges come from a static radial-gradient
that is painted once and only ever transformed.

The overlay could no longer fade as one piece. Ground and mark fade in 260ms
while the burst runs 420ms plus a 25ms per-blob stagger, so fading the
container would have taken the burst down with it halfway through. They fade
independently now and the shell unmounts at 540ms instead of 300ms, which is
the burst's real length rather than the cross-fade's.

Hidden entirely under prefers-reduced-motion, though those visitors never
reach the splash anyway.
This commit is contained in:
jwinterm
2026-08-17 14:19:56 -04:00
parent e119d1c641
commit 835c5c8084
3 changed files with 92 additions and 19 deletions
+37
View File
@@ -327,3 +327,40 @@
html[data-splash="skip"] #splash-overlay {
display: none !important;
}
/* Splash handover burst: the four pillar cells disperse as the mark hands over to the page.
The blobs sit where the monogram's four cells were (a 2x2 grid about the centre), so this
reads as the logo coming apart rather than as a generic flash.
ONLY transform and opacity animate, both compositor properties. This deliberately avoids
animating filter/background: the matrix animation pegged a core doing exactly that, because
a filter change re-renders the whole filter graph every frame. Soft edges come from a static
radial-gradient that is painted once and then only transformed. */
@keyframes splash-burst {
from { transform: translate(-50%, -50%) scale(0.35); opacity: var(--burst-peak); }
to { transform: translate(-50%, -50%) scale(3.1); opacity: 0; }
}
.splash-burst-blob {
position: absolute;
width: 34vmin;
height: 34vmin;
border-radius: 9999px;
opacity: 0;
will-change: transform, opacity;
animation: splash-burst 420ms cubic-bezier(0.16, 0.84, 0.44, 1) forwards;
/* Half-transparent by construction: the gradient's own alpha never reaches 1, and
--burst-peak holds the whole layer under half opacity. */
--burst-peak: 0.42;
}
.dark .splash-burst-blob { --burst-peak: 0.5; }
.splash-burst-s { background: radial-gradient(circle, rgb(26 92 56 / 0.55), transparent 70%); }
.splash-burst-u { background: radial-gradient(circle, rgb(184 54 112 / 0.55), transparent 70%); }
.splash-burst-c { background: radial-gradient(circle, rgb(23 106 146 / 0.55), transparent 70%); }
.splash-burst-h { background: radial-gradient(circle, rgb(18 133 126 / 0.55), transparent 70%); }
.dark .splash-burst-s { background: radial-gradient(circle, rgb(53 146 92 / 0.55), transparent 70%); }
.dark .splash-burst-u { background: radial-gradient(circle, rgb(232 104 158 / 0.55), transparent 70%); }
.dark .splash-burst-c { background: radial-gradient(circle, rgb(74 173 224 / 0.55), transparent 70%); }
.dark .splash-burst-h { background: radial-gradient(circle, rgb(58 212 198 / 0.55), transparent 70%); }
@media (prefers-reduced-motion: reduce) {
.splash-burst-blob { display: none; }
}
+53 -18
View File
@@ -12,6 +12,16 @@ const SPLASH_MS = 1337;
// gate stacked on the first.
const FADE_MS = 260;
// The four monogram cells, in the 2x2 grid the mark ends on: S and C in the left column,
// U and H in the right. The burst throws one blob out of each, in that letter's own colour,
// so the handover reads as the logo dispersing rather than as an unrelated flash.
const BURST_CELLS = [
{ key: "s", left: "44%", top: "44%" },
{ key: "u", left: "56%", top: "44%" },
{ key: "c", left: "44%", top: "56%" },
{ key: "h", left: "56%", top: "56%" },
] as const;
/**
* First-load splash: the pillar signature.
*
@@ -77,30 +87,55 @@ export function HeroSplash({ onEnter, leaving }: { onEnter: () => void; leaving?
};
}, [leaving]);
const fade = { transitionDuration: `${FADE_MS}ms`, opacity: fadeOut ? 0 : 1 };
return (
<div
id="splash-overlay"
role="presentation"
className={`fixed inset-0 z-50 flex items-center justify-center overflow-hidden bg-background ${
leaving ? `pointer-events-none transition-opacity ${fadeOut ? "opacity-0" : "opacity-100"}` : ""
className={`fixed inset-0 z-50 flex items-center justify-center overflow-hidden ${
leaving ? "pointer-events-none" : "bg-background"
}`}
style={leaving ? { transitionDuration: `${FADE_MS}ms` } : undefined}
>
{/* The mark is the only content, so it is the LCP candidate: an <img> is a valid
LCP element where an inline <svg> is not. */}
<img
src="/images/animations/morph_splash_light.svg"
alt="Such Software"
fetchPriority="high"
className="h-auto w-[min(62vw,38vh)] dark:hidden"
/>
<img
src="/images/animations/morph_splash.svg"
alt=""
aria-hidden="true"
fetchPriority="high"
className="hidden h-auto w-[min(62vw,38vh)] dark:block"
/>
{/* The ground and the mark fade on their own, NOT as one container: the burst has to
outlive them, and fading the whole overlay would take the burst down with it in
the same 260ms. */}
{leaving && (
<div className="absolute inset-0 bg-background transition-opacity" style={fade} />
)}
{/* The handover burst: one blob per monogram cell, in that cell's own colour, thrown
outward from where the letter was. aria-hidden and pointer-events-none: it is pure
decoration over content that is already interactive underneath. */}
{leaving && (
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
{BURST_CELLS.map((cell, i) => (
<span
key={cell.key}
className={`splash-burst-blob splash-burst-${cell.key}`}
style={{ left: cell.left, top: cell.top, animationDelay: `${i * 25}ms` }}
/>
))}
</div>
)}
{/* The mark is the largest thing on the first screen, so it is the LCP candidate: an
<img> is a valid LCP element where an inline <svg> is not. */}
<div className={leaving ? "relative transition-opacity" : "relative"} style={leaving ? fade : undefined}>
<img
src="/images/animations/morph_splash_light.svg"
alt="Such Software"
fetchPriority="high"
className="h-auto w-[min(62vw,38vh)] dark:hidden"
/>
<img
src="/images/animations/morph_splash.svg"
alt=""
aria-hidden="true"
fetchPriority="high"
className="hidden h-auto w-[min(62vw,38vh)] dark:block"
/>
</div>
</div>
);
}
@@ -140,7 +140,8 @@ export function HomeShell({ children }: { children: ReactNode }) {
setTimeout(() => {
setSplashUp(false);
tryRelease();
}, 300); // the overlay's 260ms cross-fade, plus a frame
}, 540); // the 420ms burst plus its 75ms stagger, not just the 260ms cross-fade:
// unmounting on the fade alone cut the blobs off mid-flight
};
return (