Files
avbeat-com/src/components/AnimatedLogo.tsx
ops-bot e43d8737cf feat(brand): wire AnimatedLogo into Header + Footer
- Header: replaces AvBeatLogo across all 3 breakpoints (full / no-tagline / iconOnly).
- Footer: replaces AvBeatLogo full variant.
- AnimatedLogo: new iconOnly prop hides the wordmark+tagline span entirely for the mobile breakpoint.

Existing AvBeatLogo file kept on disk for now (unimported, can be deleted later) so we have a quick rollback target if anyone wants the static A+V monogram back.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 16:15:01 +00:00

284 lines
9.9 KiB
TypeScript

'use client';
import React, { useId } from 'react';
/**
* AnimatedLogo
* ============
* Standalone preview component — NOT yet wired into Header or Footer.
* The existing AvBeatLogo (static A+V monogram) keeps the production
* surfaces. This component is the experimental brand mark.
*
* • Single-A monogram inside a rounded-square tile with the brand
* gradient (#2563EB → #1E3A8A).
* • Wordmark in Space Grotesk 700, loaded via next/font/google as
* --font-display in app/layout.tsx.
* • Tagline in Inter (var(--font-sans)) with an accent-rule (a short
* blue dash) preceding the text.
*
* Animations (pure CSS, no library):
* • Stroke-draw on mount ~1.1s on the A path
* • Cyan signal pulse ~3.2s loop traversing the A
* • Subtle sheen sweep ~4.4s loop diagonal across the tile
* • Wordmark + tagline fade in ~0.9s, delayed until stroke finishes
*
* All animations halt under `prefers-reduced-motion: reduce`.
*
* Props
* size — emblem edge length in px (default 64). Wordmark scales.
* variant — 'onLight' (default) | 'onDark' — chooses text colors.
* showTagline (default true) — toggle the "Inside the Future of Pro
* AV" line.
*
* Accessibility: role="img" with aria-label="AV Beat" on the wrapper.
*/
export type AnimatedLogoVariant = 'onLight' | 'onDark';
interface Props {
size?: number;
variant?: AnimatedLogoVariant;
showTagline?: boolean;
/** If true, emblem only — wordmark + tagline hidden. Use on mobile
* breakpoints where horizontal space is tight. */
iconOnly?: boolean;
className?: string;
}
export default function AnimatedLogo({
size = 64,
variant = 'onLight',
showTagline = true,
iconOnly = false,
className = '',
}: Props) {
// useId for stable, SSR-safe unique IDs (the page can render two of
// these and the gradients won't collide).
const uid = useId().replace(/[:]/g, '');
const tileGradId = `al-tile-${uid}`;
const sheenGradId = `al-sheen-${uid}`;
const wordmarkColor = variant === 'onDark' ? '#FFFFFF' : '#0F172A';
const taglineColor = variant === 'onDark' ? '#CBD5E1' : '#475569';
const accentColor = '#1D4ED8';
return (
<span
role="img"
aria-label="AV Beat"
className={`avb-anim-logo avb-anim-logo--${variant} ${className}`}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: Math.round(size * 0.22),
lineHeight: 1,
}}
>
{/* ── EMBLEM ───────────────────────────────────────────────── */}
<svg
width={size}
height={size}
viewBox="0 0 64 64"
aria-hidden="true"
focusable="false"
style={{ display: 'block', overflow: 'visible' }}
>
<defs>
<linearGradient id={tileGradId} x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
<stop offset="0%" stopColor="#2563EB" />
<stop offset="100%" stopColor="#1E3A8A" />
</linearGradient>
<linearGradient id={sheenGradId} x1="-1" y1="0" x2="1" y2="0" gradientUnits="objectBoundingBox">
<stop offset="0%" stopColor="#FFFFFF" stopOpacity="0" />
<stop offset="50%" stopColor="#FFFFFF" stopOpacity="0.32" />
<stop offset="100%" stopColor="#FFFFFF" stopOpacity="0" />
</linearGradient>
{/* Clip the sheen to the rounded tile so the gradient sweep
never bleeds outside the corner radius. */}
<clipPath id={`al-clip-${uid}`}>
<rect x="0" y="0" width="64" height="64" rx="14" ry="14" />
</clipPath>
</defs>
{/* Tile */}
<rect x="0" y="0" width="64" height="64" rx="14" ry="14" fill={`url(#${tileGradId})`} />
{/* Sheen sweep — a tall skewed band that travels left→right. */}
<g clipPath={`url(#al-clip-${uid})`}>
<rect
className="al-sheen"
x="-32"
y="-8"
width="32"
height="80"
fill={`url(#${sheenGradId})`}
transform="skewX(-18)"
/>
</g>
{/*
Single-A monogram. Built as two strokes so the stroke-draw
animation cleanly traces the legs of the A and the crossbar.
pathLength=100 normalizes the dash math regardless of unit.
*/}
{/* Left leg + right leg in one continuous stroke for a clean
single dash-draw. The path goes (bottom-left → apex →
bottom-right). */}
<path
className="al-stroke"
d="M 14 50 L 32 14 L 50 50"
fill="none"
stroke="#FFFFFF"
strokeWidth="5"
strokeLinecap="round"
strokeLinejoin="round"
pathLength={100}
/>
{/* Crossbar — a second short stroke, dashed-drawn in sequence. */}
<path
className="al-stroke al-stroke--bar"
d="M 21 36 L 43 36"
fill="none"
stroke="#FFFFFF"
strokeWidth="4"
strokeLinecap="round"
pathLength={100}
/>
{/*
Cyan signal pulse — a small dot that rides along the same
path as the A's outline. We re-use the left-leg-to-apex-to-
right-leg path via <animateMotion> attached to a path
reference. Falls back gracefully when reduce-motion is on.
*/}
<circle r="2.6" fill="#7DD3FC" className="al-pulse" cx="14" cy="50">
<animateMotion
dur="3.2s"
repeatCount="indefinite"
rotate="0"
keyTimes="0;1"
keyPoints="0;1"
>
<mpath href={`#al-path-${uid}`} />
</animateMotion>
</circle>
{/* Hidden path the pulse rides along. Same geometry as visible
A so the dot tracks the silhouette. */}
<path id={`al-path-${uid}`} d="M 14 50 L 32 14 L 50 50" fill="none" stroke="none" />
</svg>
{/* ── WORDMARK + TAGLINE ───────────────────────────────────── */}
{!iconOnly && (
<span
className="al-text"
style={{ display: 'inline-flex', flexDirection: 'column', gap: Math.round(size * 0.08), minWidth: 0 }}
>
<span
style={{
fontFamily: 'var(--font-display), "Space Grotesk", Inter, -apple-system, BlinkMacSystemFont, sans-serif',
fontWeight: 700,
fontSize: Math.round(size * 0.5),
letterSpacing: '-0.012em',
color: wordmarkColor,
lineHeight: 0.95,
textTransform: 'uppercase',
whiteSpace: 'nowrap',
}}
>
AV BEAT
</span>
{showTagline && (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: Math.round(size * 0.12),
fontFamily: 'var(--font-sans), -apple-system, BlinkMacSystemFont, sans-serif',
fontWeight: 500,
fontSize: Math.round(size * 0.165),
letterSpacing: '0.06em',
color: taglineColor,
lineHeight: 1.1,
whiteSpace: 'nowrap',
textTransform: 'uppercase',
}}
>
<span
aria-hidden="true"
style={{
display: 'inline-block',
width: Math.round(size * 0.28),
height: 2,
background: accentColor,
borderRadius: 1,
}}
/>
Inside the Future of Pro AV
</span>
)}
</span>
)}
{/*
Component-scoped CSS. Keeping it inline (style jsx-free) so the
component is portable to any Next.js page without globals leak.
prefers-reduced-motion blanket-disables every keyframe.
*/}
<style>{`
.avb-anim-logo .al-stroke {
stroke-dasharray: 100;
stroke-dashoffset: 100;
animation: al-draw 1.1s 0.05s cubic-bezier(0.65, 0, 0.35, 1) forwards;
}
.avb-anim-logo .al-stroke--bar {
animation-delay: 0.85s;
animation-duration: 0.55s;
}
.avb-anim-logo .al-sheen {
animation: al-sheen 4.4s 1.1s cubic-bezier(0.55, 0.05, 0.45, 0.95) infinite;
}
.avb-anim-logo .al-pulse {
opacity: 0;
animation: al-pulse-fade 0.6s 1.05s ease-out forwards,
al-pulse-glow 3.2s 1.65s ease-in-out infinite;
}
.avb-anim-logo .al-text {
opacity: 0;
transform: translateX(-4px);
animation: al-fade-in 0.9s 1.0s cubic-bezier(0.2, 0, 0, 1) forwards;
}
@keyframes al-draw {
to { stroke-dashoffset: 0; }
}
@keyframes al-sheen {
0% { transform: translateX(-30px) skewX(-18deg); }
55% { transform: translateX(110px) skewX(-18deg); }
100% { transform: translateX(110px) skewX(-18deg); }
}
@keyframes al-pulse-fade {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes al-pulse-glow {
0%, 100% { filter: drop-shadow(0 0 1px rgba(125, 211, 252, 0.6)); }
50% { filter: drop-shadow(0 0 6px rgba(125, 211, 252, 0.95)); }
}
@keyframes al-fade-in {
to { opacity: 1; transform: translateX(0); }
}
@media (prefers-reduced-motion: reduce) {
.avb-anim-logo .al-stroke {
stroke-dashoffset: 0 !important;
animation: none !important;
}
.avb-anim-logo .al-sheen,
.avb-anim-logo .al-pulse,
.avb-anim-logo .al-text {
animation: none !important;
}
.avb-anim-logo .al-text { opacity: 1; transform: none; }
.avb-anim-logo .al-pulse { opacity: 1; }
}
`}</style>
</span>
);
}