feat: animate the logo in the navbar

This commit is contained in:
2026-09-23 22:47:04 +02:00
parent c0daacf1ba
commit 93b2fcb2ae
7 changed files with 346 additions and 305 deletions
+67 -20
View File
@@ -3,21 +3,43 @@ import { Link } from "react-router";
import { cx, FOCUS_RING } from "./styles";
import type { NavSurface } from "./types";
/* Exported from Figma logo/sharp-er (317:5571) and logo/sharp-er-dark
(317:7812). Intrinsic 334x46; the navigation draws it at 100px wide, which
is the designed size in every navigation frame (desktop and mobile). */
const LOGO_SRC: Record<NavSurface, string> = {
light: "/assets/sharp-er-logo.png",
dark: "/assets/sharp-er-logo-dark.png",
};
/** One half of the animated brand lockup, drawn as its own SVG file. */
interface BrandMark {
src: string;
/** Intrinsic size of the file, for the aspect ratio the browser reserves. */
width: number;
height: number;
/** The mark's own drawn width, so the pair flexes to one scale. */
drawnWidth: number;
}
const LOGO_INTRINSIC_WIDTH = 334;
const LOGO_INTRINSIC_HEIGHT = 46;
/**
* The SHARP-ER lockup, split into its two drawn pieces so the "-ER" can slide
* out from behind the P and fade in once the page has settled (Navigation.css).
* Each surface ships its own export of the two pieces: white on the dark
* surface, the two-tone ink on the light surface. Both exports of a piece are
* the exact same box (218x46 and 112x46), so the pair shares one scale; each
* mark takes the share of the row its box gives it, and the suffix rides flush
* against the P's right edge.
*/
const BRAND_LOCKUP: Record<
NavSurface,
{ base: BrandMark; suffix: BrandMark }
> = {
light: {
base: { src: "/assets/sharp-wordmark.svg", width: 218, height: 46, drawnWidth: 218 },
suffix: { src: "/assets/sharp-er-suffix.svg", width: 112, height: 46, drawnWidth: 112 },
},
dark: {
base: { src: "/assets/sharp-wordmark-inverse.svg", width: 218, height: 46, drawnWidth: 218 },
suffix: { src: "/assets/sharp-er-suffix-inverse.svg", width: 112, height: 46, drawnWidth: 112 },
},
};
export interface BrandProps {
/** Locale-prefixed home path, e.g. "/en". */
href: string;
/** Accessible name of the link; the image itself is decorative. */
/** Accessible name of the link; the images are decorative. */
label: string;
surface: NavSurface;
className?: string;
@@ -25,6 +47,8 @@ export interface BrandProps {
/** The SHARP-ER wordmark, linking home. Sits at the inline start of the bar. */
export function Brand({ href, label, surface, className }: BrandProps) {
const { base, suffix } = BRAND_LOCKUP[surface];
return (
<Link
to={href}
@@ -35,16 +59,39 @@ export function Brand({ href, label, surface, className }: BrandProps) {
className,
)}
>
<img
src={LOGO_SRC[surface]}
alt=""
width={LOGO_INTRINSIC_WIDTH}
height={LOGO_INTRINSIC_HEIGHT}
loading="eager"
decoding="async"
draggable={false}
className="block h-auto w-[100px]"
/>
{/* `dir="ltr"` because a Latin wordmark never mirrors; its position on
the bar still follows the page's direction. */}
<span dir="ltr" className="flex w-[100px] items-start">
<img
src={base.src}
alt=""
width={base.width}
height={base.height}
loading="eager"
decoding="async"
draggable={false}
style={{ flex: `${base.drawnWidth} 1 0%` }}
className="block h-auto min-w-0"
/>
{/* The clipping box starts at the P's right edge, so the mark inside
it can slide out from behind the P (Navigation.css). */}
<span
style={{ flex: `${suffix.drawnWidth} 1 0%` }}
className="sh-brand-suffix block min-w-0"
>
<img
src={suffix.src}
alt=""
width={suffix.width}
height={suffix.height}
loading="eager"
decoding="async"
draggable={false}
className="sh-brand-suffix__mark block h-auto w-full"
/>
</span>
</span>
</Link>
);
}
@@ -18,6 +18,54 @@
animation: sh-nav-menu-in var(--motion-duration-base)
var(--motion-ease-entrance) both;
}
/* The "-ER" after "SHARP" in the brand, sliding out from behind the P and
fading in once the page has settled. The same reveal the home hero used to
run on its wordmark, now owned by the navigation. The suffix sits in its
own box whose inline-start edge is the P's right edge, and that box clips;
the mark starts a full box-width back - entirely beside "SHARP", so
nothing overlaps it - and travels forward to rest, with the opacity
reaching 1 before the travel ends, so the glyphs read as solid for the
last, slowest part of the decelerating slide. The translate is physical
(translateX) on purpose: the lockup row is pinned to dir="ltr" because a
Latin wordmark never mirrors, so the P is always left of the suffix, in
Arabic too. Pure CSS, so it runs from the server-rendered HTML without
waiting for hydration, and the `both` fill keeps the suffix hidden through
the delay instead of flashing in first. It replays whenever the navigation
mounts again, e.g. on a client-side navigation back to a page. Under
prefers-reduced-motion the animation is removed outright - app.css only
collapses the duration and would leave the delay holding the suffix
hidden - so the full name is simply there from the start. */
.sh-brand-suffix {
overflow: hidden;
}
.sh-brand-suffix__mark {
animation: sh-brand-suffix-in var(--motion-duration-reveal)
var(--motion-ease-entrance) var(--motion-delay-reveal) both;
}
@media (prefers-reduced-motion: reduce) {
.sh-brand-suffix__mark {
animation: none;
}
}
}
@keyframes sh-brand-suffix-in {
from {
opacity: 0;
transform: translateX(-100%);
}
60% {
opacity: 1;
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes sh-nav-menu-in {
@@ -7,6 +7,8 @@ import {
type Locale,
} from "../LanguageSwitcher";
import "./Navigation.css";
import { Brand } from "./Brand";
import { DesktopNav } from "./DesktopNav";
import { MobileNav } from "./MobileNav";