Replay the loader logo animation on hover; narrow hero text

- Extract the logo into an inline-SVG LogoMark shared by the loader and
  the site logo; one CSS animation driven by `.is-animating`
- Hovering/focusing the logo redraws it from a faint ghost and runs one
  wave along the facets; a replay always finishes and can't restart
  midway; skipped for reduced motion
- Cap hero subtitles (inner pages + home intro) at 36rem with pretty
  wrapping so the About hero no longer runs across half the screen

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-11-05 07:33:42 +01:00
co-authored by Claude Opus 5.5
parent c3514d2607
commit eb08226343
6 changed files with 161 additions and 64 deletions
+48 -12
View File
@@ -1,21 +1,57 @@
import React from "react";
import LogoMark from "./LogoMark";
type Props = {
size?: number;
};
/**
* Site logo linking home. Hovering (or focusing) it replays the loader's
* logo animation once: the mark redraws itself from a faint ghost and a
* single wave runs along the base facets. A replay always runs to the end,
* even if the pointer leaves, and a new hover can't restart it midway.
*/
export default function Logo({ size }: Props) {
const sizeRatio = 50 / 100;
const width = size || 50;
const height = width * sizeRatio;
const markRef = React.useRef<SVGSVGElement>(null);
const fallback = React.useRef<number | undefined>(undefined);
const stop = React.useCallback(() => {
window.clearTimeout(fallback.current);
markRef.current?.classList.remove("is-animating");
}, []);
const play = React.useCallback(() => {
const el = markRef.current;
if (!el || el.classList.contains("is-animating")) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
return;
}
el.classList.add("is-animating");
// in case animationend never arrives (e.g. styles not loaded)
fallback.current = window.setTimeout(stop, 4000);
}, [stop]);
// the sequence ends with the last facet's wave
const onAnimationEnd = (e: React.AnimationEvent<SVGSVGElement>) => {
if (e.target === markRef.current?.lastElementChild) stop();
};
React.useEffect(() => stop, [stop]);
return (
<a href="/">
<img
src="/images/logo-white.svg"
alt="Main Logo"
width={width}
height={height}
style={{
minWidth: width + "px",
}}
<a
href="/"
className="inline-flex"
onPointerEnter={play}
onFocus={play}
>
<LogoMark
ref={markRef}
size={size || 50}
label="Main Logo"
className="logo-mark--hover"
style={{ minWidth: (size || 50) + "px" }}
onAnimationEnd={onAnimationEnd}
/>
</a>
);
+63
View File
@@ -0,0 +1,63 @@
import React from "react";
import { twMerge } from "tailwind-merge";
/**
* The logo as inline SVG, split into parts so it can animate (see the
* "Logo mark" section of globals.css). Add `is-animating` to play the
* sequence: the "B" draws in and fills, the five base facets trace in after
* it, then a wave runs along the facets (`--pl-waves` times, default
* forever). Without it the mark is just the solid logo.
*/
/** Base facets, left → right along the zigzag (wave order). */
const FACETS = [
"M151.94,875.51L151.94,1042.32L0,958.98L0,786.14L151.94,875.51Z",
"M306.83,789.02L306.83,957.3L151.94,1042.32L151.94,875.51L156.15,873.19L306.83,789.02Z",
"M461.71,874.03L461.71,1042.32L306.83,957.3L306.83,789.02L461.71,874.03Z",
"M610.63,784.67L610.63,952.95L461.71,1042.32L461.71,874.03L464.52,872.35L610.63,784.67Z",
"M610.63,619.33L610.63,784.67L461.71,702.53L468.56,698.7L563.9,645.44L610.63,619.33Z",
];
const LETTER =
"M610.63,515.37L610.63,619.37L590.72,607.63L461.72,531.55L306.83,617L151.94,702.39L138.33,709.89L0,786.14L0,0L362,0C434.173,0 490.98,15.707 532.42,47.12C534.38,48.613 536.297,50.137 538.17,51.69C575.177,82.423 593.98,125.773 594.58,181.74L594.58,184.49C594.58,225.95 583.717,260.877 561.99,289.27C540.263,317.663 511.353,336.547 475.26,345.92C518.027,355.92 551.283,376.477 575.03,407.59C598.777,438.703 610.643,474.63 610.63,515.37ZM368.49,519.89C378.163,512.21 383,499.673 383,482.28C383,448.193 363.28,431.15 323.84,431.15L222.6,431.15L222.6,531.42L323.87,531.42C343.923,531.42 358.797,527.577 368.49,519.89ZM368,227.61C368,210.23 363.153,197.36 353.46,189C343.767,180.64 328.893,176.463 308.84,176.47L222.6,176.47L222.6,276.74L308.83,276.74C328.877,276.74 343.75,272.74 353.45,264.74C363.15,256.74 368,244.363 368,227.61Z";
const RATIO = 1043 / 611;
type Props = React.SVGProps<SVGSVGElement> & {
/** Rendered width in px; height follows the logo's aspect ratio. */
size?: number;
label?: string;
};
const LogoMark = React.forwardRef<SVGSVGElement, Props>(function LogoMark(
{ size = 50, label, className, ...rest },
ref,
) {
return (
<svg
ref={ref}
viewBox="0 0 611 1043"
width={size}
height={Math.round(size * RATIO * 100) / 100}
fillRule="evenodd"
className={twMerge("logo-mark", className)}
{...(label
? { role: "img", "aria-label": label }
: { "aria-hidden": true })}
{...rest}
>
<path className="pl-shape pl-letter" d={LETTER} pathLength={1} />
{FACETS.map((d, i) => (
<g
key={i}
className="pl-facet"
style={{ "--i": i } as React.CSSProperties}
>
<path className="pl-shape" d={d} pathLength={1} />
</g>
))}
</svg>
);
});
export default LogoMark;
+1 -1
View File
@@ -69,7 +69,7 @@ export default function PageHero({
</H1>
{subtitle && (
<Span
className="text-xl"
className="text-xl max-w-[36rem] text-pretty"
data-reveal="up"
data-reveal-delay="0.35"
>
+7 -34
View File
@@ -1,8 +1,11 @@
import LogoMark from "../general/LogoMark";
/**
* Full-screen loader shown until the page's entry animations are ready
* (see ./gate.ts). It's just the logo, animating: the "B" draws itself in
* and fills, the five base facets trace in after it, then a wave runs along
* the facets until the page is ready. Pure CSS, so it plays before any JS.
* (see ./gate.ts). It's just the logo, animating (see LogoMark): the "B"
* draws itself in and fills, the five base facets trace in after it, then a
* wave runs along the facets until the page is ready. Pure CSS, so it plays
* before any JS.
*
* Plain server-rendered markup driven only by classes on <html>, so React
* never touches it after hydration:
@@ -13,40 +16,10 @@
* - `html.loader-gone` fully hidden; animations paused
* - `html.loader-leaving` fading back in for a page exit
*/
/** Base facets, left → right along the zigzag (wave order). */
const FACETS = [
"M151.94,875.51L151.94,1042.32L0,958.98L0,786.14L151.94,875.51Z",
"M306.83,789.02L306.83,957.3L151.94,1042.32L151.94,875.51L156.15,873.19L306.83,789.02Z",
"M461.71,874.03L461.71,1042.32L306.83,957.3L306.83,789.02L461.71,874.03Z",
"M610.63,784.67L610.63,952.95L461.71,1042.32L461.71,874.03L464.52,872.35L610.63,784.67Z",
"M610.63,619.33L610.63,784.67L461.71,702.53L468.56,698.7L563.9,645.44L610.63,619.33Z",
];
const LETTER =
"M610.63,515.37L610.63,619.37L590.72,607.63L461.72,531.55L306.83,617L151.94,702.39L138.33,709.89L0,786.14L0,0L362,0C434.173,0 490.98,15.707 532.42,47.12C534.38,48.613 536.297,50.137 538.17,51.69C575.177,82.423 593.98,125.773 594.58,181.74L594.58,184.49C594.58,225.95 583.717,260.877 561.99,289.27C540.263,317.663 511.353,336.547 475.26,345.92C518.027,355.92 551.283,376.477 575.03,407.59C598.777,438.703 610.643,474.63 610.63,515.37ZM368.49,519.89C378.163,512.21 383,499.673 383,482.28C383,448.193 363.28,431.15 323.84,431.15L222.6,431.15L222.6,531.42L323.87,531.42C343.923,531.42 358.797,527.577 368.49,519.89ZM368,227.61C368,210.23 363.153,197.36 353.46,189C343.767,180.64 328.893,176.463 308.84,176.47L222.6,176.47L222.6,276.74L308.83,276.74C328.877,276.74 343.75,272.74 353.45,264.74C363.15,256.74 368,244.363 368,227.61Z";
export default function PageLoader() {
return (
<div className="page-loader" aria-hidden="true">
<svg
className="page-loader-logo"
viewBox="0 0 611 1043"
width={56}
height={96}
fillRule="evenodd"
>
<path className="pl-shape pl-letter" d={LETTER} pathLength={1} />
{FACETS.map((d, i) => (
<g
key={i}
className="pl-facet"
style={{ "--i": i } as React.CSSProperties}
>
<path className="pl-shape" d={d} pathLength={1} />
</g>
))}
</svg>
<LogoMark size={56} className="page-loader-logo is-animating" />
</div>
);
}
+1 -1
View File
@@ -40,7 +40,7 @@ export default function Main() {
CTO, DevOps & Platform Engineer.
</H1>
<Span
className="text-xl"
className="text-xl max-w-[36rem] text-pretty"
data-reveal="up"
data-reveal-delay="0.4"
>
+41 -16
View File
@@ -152,13 +152,8 @@ html.motion.loader-leaving .page-loader {
transition-duration: 0.32s, 0s;
}
/* -- the logo -- */
.page-loader-logo {
--d0: 0.1s; /* start offset for the whole sequence */
width: 56px;
height: auto;
overflow: visible;
color: #fff;
--d0: 0.1s;
transition:
opacity 0.45s ease,
transform 0.7s cubic-bezier(0.65, 0, 0.35, 1);
@@ -174,27 +169,56 @@ html.loader-leaving .page-loader-logo {
opacity: 0;
}
.pl-shape {
/* ---- Logo mark (see src/components/general/LogoMark.tsx) ----
Solid by default; `.is-animating` plays the sequence. Tunables:
--d0 start offset, --pl-stroke outline width (viewBox units, so raise it
for small renders), --pl-fill-from starting fill (0 = draws from nothing),
--pl-waves how many times the wave runs. */
.logo-mark {
--d0: 0s;
--pl-stroke: 9;
--pl-fill-from: 0;
--pl-waves: infinite;
height: auto;
overflow: visible;
color: #fff;
}
.logo-mark .pl-shape {
fill: currentColor;
fill-opacity: 0;
}
/* Nav logo on hover: redraw from a faint ghost, one wave. */
.logo-mark--hover {
--pl-stroke: 26;
--pl-fill-from: 0.15;
--pl-waves: 1;
}
.logo-mark.is-animating .pl-shape {
fill-opacity: var(--pl-fill-from);
stroke: currentColor;
stroke-width: 9;
stroke-width: var(--pl-stroke);
stroke-linejoin: round;
stroke-dasharray: 1 1;
stroke-dashoffset: 1;
}
/* 1. the "B" draws itself in, then fills */
.pl-letter {
.logo-mark.is-animating .pl-letter {
animation:
pl-draw 1s cubic-bezier(0.65, 0, 0.35, 1) var(--d0) forwards,
pl-fill 0.55s ease calc(var(--d0) + 0.65s) forwards;
}
/* 2. the facets trace in and rise into place, left → right */
.pl-facet .pl-shape {
.logo-mark .pl-facet,
.logo-mark .pl-facet .pl-shape {
transform-box: fill-box;
transform-origin: center;
}
.logo-mark.is-animating .pl-facet .pl-shape {
animation:
pl-draw 0.6s ease-out calc(var(--d0) + 0.45s + var(--i) * 0.08s)
forwards,
@@ -204,12 +228,10 @@ html.loader-leaving .page-loader-logo {
calc(var(--d0) + 0.45s + var(--i) * 0.08s) both;
}
/* 3. while waiting: a signal runs along the facets */
.pl-facet {
transform-box: fill-box;
transform-origin: center;
/* 3. a signal runs along the facets */
.logo-mark.is-animating .pl-facet {
animation: pl-wave 1.8s ease-in-out
calc(var(--d0) + 1.35s + var(--i) * 0.11s) infinite;
calc(var(--d0) + 1.35s + var(--i) * 0.11s) var(--pl-waves);
}
@keyframes pl-draw {
@@ -219,6 +241,9 @@ html.loader-leaving .page-loader-logo {
}
@keyframes pl-fill {
from {
fill-opacity: var(--pl-fill-from);
}
to {
fill-opacity: 1;
}