From eb08226343fb46b5559dc499c31dfa44a4324124 Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Thu, 5 Nov 2026 07:33:42 +0100 Subject: [PATCH] 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 --- src/components/general/Logo.tsx | 60 ++++++++++++++++++++------ src/components/general/LogoMark.tsx | 63 ++++++++++++++++++++++++++++ src/components/general/page-hero.tsx | 2 +- src/components/loader/PageLoader.tsx | 41 ++++-------------- src/components/pages/Home/index.tsx | 2 +- src/styles/globals.css | 57 ++++++++++++++++++------- 6 files changed, 161 insertions(+), 64 deletions(-) create mode 100644 src/components/general/LogoMark.tsx diff --git a/src/components/general/Logo.tsx b/src/components/general/Logo.tsx index f2ef82a..37641b3 100644 --- a/src/components/general/Logo.tsx +++ b/src/components/general/Logo.tsx @@ -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(null); + const fallback = React.useRef(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) => { + if (e.target === markRef.current?.lastElementChild) stop(); + }; + + React.useEffect(() => stop, [stop]); return ( - - Main Logo + ); diff --git a/src/components/general/LogoMark.tsx b/src/components/general/LogoMark.tsx new file mode 100644 index 0000000..5ae2c37 --- /dev/null +++ b/src/components/general/LogoMark.tsx @@ -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 & { + /** Rendered width in px; height follows the logo's aspect ratio. */ + size?: number; + label?: string; +}; + +const LogoMark = React.forwardRef(function LogoMark( + { size = 50, label, className, ...rest }, + ref, +) { + return ( + + + {FACETS.map((d, i) => ( + + + + ))} + + ); +}); + +export default LogoMark; diff --git a/src/components/general/page-hero.tsx b/src/components/general/page-hero.tsx index aa91086..caef705 100644 --- a/src/components/general/page-hero.tsx +++ b/src/components/general/page-hero.tsx @@ -69,7 +69,7 @@ export default function PageHero({ {subtitle && ( diff --git a/src/components/loader/PageLoader.tsx b/src/components/loader/PageLoader.tsx index df724b3..6a0127a 100644 --- a/src/components/loader/PageLoader.tsx +++ b/src/components/loader/PageLoader.tsx @@ -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 , 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 ( ); } diff --git a/src/components/pages/Home/index.tsx b/src/components/pages/Home/index.tsx index cc98b87..46eed4c 100644 --- a/src/components/pages/Home/index.tsx +++ b/src/components/pages/Home/index.tsx @@ -40,7 +40,7 @@ export default function Main() { CTO, DevOps & Platform Engineer. diff --git a/src/styles/globals.css b/src/styles/globals.css index fb7ef54..ef57d44 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -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; }