mirror of
https://github.com/Such-Software/such.software.git
synced 2026-10-17 05:32:05 +02:00
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:
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user