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:
@@ -1,21 +1,57 @@
|
|||||||
|
import React from "react";
|
||||||
|
import LogoMark from "./LogoMark";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
size?: number;
|
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) {
|
export default function Logo({ size }: Props) {
|
||||||
const sizeRatio = 50 / 100;
|
const markRef = React.useRef<SVGSVGElement>(null);
|
||||||
const width = size || 50;
|
const fallback = React.useRef<number | undefined>(undefined);
|
||||||
const height = width * sizeRatio;
|
|
||||||
|
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 (
|
return (
|
||||||
<a href="/">
|
<a
|
||||||
<img
|
href="/"
|
||||||
src="/images/logo-white.svg"
|
className="inline-flex"
|
||||||
alt="Main Logo"
|
onPointerEnter={play}
|
||||||
width={width}
|
onFocus={play}
|
||||||
height={height}
|
>
|
||||||
style={{
|
<LogoMark
|
||||||
minWidth: width + "px",
|
ref={markRef}
|
||||||
}}
|
size={size || 50}
|
||||||
|
label="Main Logo"
|
||||||
|
className="logo-mark--hover"
|
||||||
|
style={{ minWidth: (size || 50) + "px" }}
|
||||||
|
onAnimationEnd={onAnimationEnd}
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -69,7 +69,7 @@ export default function PageHero({
|
|||||||
</H1>
|
</H1>
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<Span
|
<Span
|
||||||
className="text-xl"
|
className="text-xl max-w-[36rem] text-pretty"
|
||||||
data-reveal="up"
|
data-reveal="up"
|
||||||
data-reveal-delay="0.35"
|
data-reveal-delay="0.35"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import LogoMark from "../general/LogoMark";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full-screen loader shown until the page's entry animations are ready
|
* 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
|
* (see ./gate.ts). It's just the logo, animating (see LogoMark): the "B"
|
||||||
* and fills, the five base facets trace in after it, then a wave runs along
|
* draws itself in and fills, the five base facets trace in after it, then a
|
||||||
* the facets until the page is ready. Pure CSS, so it plays before any JS.
|
* 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
|
* Plain server-rendered markup driven only by classes on <html>, so React
|
||||||
* never touches it after hydration:
|
* never touches it after hydration:
|
||||||
@@ -13,40 +16,10 @@
|
|||||||
* - `html.loader-gone` fully hidden; animations paused
|
* - `html.loader-gone` fully hidden; animations paused
|
||||||
* - `html.loader-leaving` fading back in for a page exit
|
* - `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() {
|
export default function PageLoader() {
|
||||||
return (
|
return (
|
||||||
<div className="page-loader" aria-hidden="true">
|
<div className="page-loader" aria-hidden="true">
|
||||||
<svg
|
<LogoMark size={56} className="page-loader-logo is-animating" />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function Main() {
|
|||||||
CTO, DevOps & Platform Engineer.
|
CTO, DevOps & Platform Engineer.
|
||||||
</H1>
|
</H1>
|
||||||
<Span
|
<Span
|
||||||
className="text-xl"
|
className="text-xl max-w-[36rem] text-pretty"
|
||||||
data-reveal="up"
|
data-reveal="up"
|
||||||
data-reveal-delay="0.4"
|
data-reveal-delay="0.4"
|
||||||
>
|
>
|
||||||
|
|||||||
+41
-16
@@ -152,13 +152,8 @@ html.motion.loader-leaving .page-loader {
|
|||||||
transition-duration: 0.32s, 0s;
|
transition-duration: 0.32s, 0s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -- the logo -- */
|
|
||||||
.page-loader-logo {
|
.page-loader-logo {
|
||||||
--d0: 0.1s; /* start offset for the whole sequence */
|
--d0: 0.1s;
|
||||||
width: 56px;
|
|
||||||
height: auto;
|
|
||||||
overflow: visible;
|
|
||||||
color: #fff;
|
|
||||||
transition:
|
transition:
|
||||||
opacity 0.45s ease,
|
opacity 0.45s ease,
|
||||||
transform 0.7s cubic-bezier(0.65, 0, 0.35, 1);
|
transform 0.7s cubic-bezier(0.65, 0, 0.35, 1);
|
||||||
@@ -174,27 +169,56 @@ html.loader-leaving .page-loader-logo {
|
|||||||
opacity: 0;
|
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: 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: currentColor;
|
||||||
stroke-width: 9;
|
stroke-width: var(--pl-stroke);
|
||||||
stroke-linejoin: round;
|
stroke-linejoin: round;
|
||||||
stroke-dasharray: 1 1;
|
stroke-dasharray: 1 1;
|
||||||
stroke-dashoffset: 1;
|
stroke-dashoffset: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 1. the "B" draws itself in, then fills */
|
/* 1. the "B" draws itself in, then fills */
|
||||||
.pl-letter {
|
.logo-mark.is-animating .pl-letter {
|
||||||
animation:
|
animation:
|
||||||
pl-draw 1s cubic-bezier(0.65, 0, 0.35, 1) var(--d0) forwards,
|
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;
|
pl-fill 0.55s ease calc(var(--d0) + 0.65s) forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 2. the facets trace in and rise into place, left → right */
|
/* 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-box: fill-box;
|
||||||
transform-origin: center;
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-mark.is-animating .pl-facet .pl-shape {
|
||||||
animation:
|
animation:
|
||||||
pl-draw 0.6s ease-out calc(var(--d0) + 0.45s + var(--i) * 0.08s)
|
pl-draw 0.6s ease-out calc(var(--d0) + 0.45s + var(--i) * 0.08s)
|
||||||
forwards,
|
forwards,
|
||||||
@@ -204,12 +228,10 @@ html.loader-leaving .page-loader-logo {
|
|||||||
calc(var(--d0) + 0.45s + var(--i) * 0.08s) both;
|
calc(var(--d0) + 0.45s + var(--i) * 0.08s) both;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 3. while waiting: a signal runs along the facets */
|
/* 3. a signal runs along the facets */
|
||||||
.pl-facet {
|
.logo-mark.is-animating .pl-facet {
|
||||||
transform-box: fill-box;
|
|
||||||
transform-origin: center;
|
|
||||||
animation: pl-wave 1.8s ease-in-out
|
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 {
|
@keyframes pl-draw {
|
||||||
@@ -219,6 +241,9 @@ html.loader-leaving .page-loader-logo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pl-fill {
|
@keyframes pl-fill {
|
||||||
|
from {
|
||||||
|
fill-opacity: var(--pl-fill-from);
|
||||||
|
}
|
||||||
to {
|
to {
|
||||||
fill-opacity: 1;
|
fill-opacity: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user