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 = {
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
{subtitle && (
|
||||
<Span
|
||||
className="text-xl"
|
||||
className="text-xl max-w-[36rem] text-pretty"
|
||||
data-reveal="up"
|
||||
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
|
||||
* (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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user