From 93b2fcb2aed1b6b4c5d5ce79998a6b16520bccd8 Mon Sep 17 00:00:00 2001 From: Hisham Aburaqibah Date: Wed, 23 Sep 2026 22:47:04 +0200 Subject: [PATCH] feat: animate the logo in the navbar --- .../app/components/ui/Navigation/Brand.tsx | 87 +++- .../components/ui/Navigation/Navigation.css | 48 +++ .../components/ui/Navigation/Navigation.tsx | 2 + .../public/assets/sharp-er-suffix-inverse.svg | 23 +- apps/web/public/assets/sharp-er-suffix.svg | 22 + .../public/assets/sharp-wordmark-inverse.svg | 376 +++++------------- apps/web/public/assets/sharp-wordmark.svg | 93 +++++ 7 files changed, 346 insertions(+), 305 deletions(-) create mode 100644 apps/web/public/assets/sharp-er-suffix.svg create mode 100644 apps/web/public/assets/sharp-wordmark.svg diff --git a/apps/web/app/components/ui/Navigation/Brand.tsx b/apps/web/app/components/ui/Navigation/Brand.tsx index 2620498..ce49ed8 100644 --- a/apps/web/app/components/ui/Navigation/Brand.tsx +++ b/apps/web/app/components/ui/Navigation/Brand.tsx @@ -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 = { - 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 ( - + {/* `dir="ltr"` because a Latin wordmark never mirrors; its position on + the bar still follows the page's direction. */} + + + + {/* The clipping box starts at the P's right edge, so the mark inside + it can slide out from behind the P (Navigation.css). */} + + + + ); } diff --git a/apps/web/app/components/ui/Navigation/Navigation.css b/apps/web/app/components/ui/Navigation/Navigation.css index b2b4e13..3a14c20 100644 --- a/apps/web/app/components/ui/Navigation/Navigation.css +++ b/apps/web/app/components/ui/Navigation/Navigation.css @@ -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 { diff --git a/apps/web/app/components/ui/Navigation/Navigation.tsx b/apps/web/app/components/ui/Navigation/Navigation.tsx index f729f0e..8d34730 100644 --- a/apps/web/app/components/ui/Navigation/Navigation.tsx +++ b/apps/web/app/components/ui/Navigation/Navigation.tsx @@ -7,6 +7,8 @@ import { type Locale, } from "../LanguageSwitcher"; +import "./Navigation.css"; + import { Brand } from "./Brand"; import { DesktopNav } from "./DesktopNav"; import { MobileNav } from "./MobileNav"; diff --git a/apps/web/public/assets/sharp-er-suffix-inverse.svg b/apps/web/public/assets/sharp-er-suffix-inverse.svg index 570aa80..7683244 100644 --- a/apps/web/public/assets/sharp-er-suffix-inverse.svg +++ b/apps/web/public/assets/sharp-er-suffix-inverse.svg @@ -1 +1,22 @@ - + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/public/assets/sharp-er-suffix.svg b/apps/web/public/assets/sharp-er-suffix.svg new file mode 100644 index 0000000..0ccb911 --- /dev/null +++ b/apps/web/public/assets/sharp-er-suffix.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/public/assets/sharp-wordmark-inverse.svg b/apps/web/public/assets/sharp-wordmark-inverse.svg index 79b1cbd..e30b3af 100644 --- a/apps/web/public/assets/sharp-wordmark-inverse.svg +++ b/apps/web/public/assets/sharp-wordmark-inverse.svg @@ -1,285 +1,93 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/web/public/assets/sharp-wordmark.svg b/apps/web/public/assets/sharp-wordmark.svg new file mode 100644 index 0000000..4a68e68 --- /dev/null +++ b/apps/web/public/assets/sharp-wordmark.svg @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +