Pattern
Logo mask hero
A giant mark with footage through its letters; scrolling flies into it.
betav0.1.0 Chromium 103 ui-scale safe
Install
Copies the file into
components/ds/, with its registry dependencies and npm packages. See Installation for the one-time setup.bunx shadcn@3 add @fastrp/logo-mask-heroExamples
Default
Türkiye’nin GTA V roleplay şehri
Kaydır
Şehir seni
bekliyor
Sonraki bölüm
API
<LogoMaskHero />The first screen, told by scrolling: a giant mark with the city showing through its letters; scrolling flies the camera into the bolt until the footage fills the screen, and the content comes up over it. The stage is sticky and the flight is the reader's own scroll, so it plays under reduced motion too — only the content's rise is dropped.
| Prop | Type | Default | Notes |
|---|---|---|---|
| children* | ReactNode | — | The headline, the way in and the links — they come up once the city fills the screen. |
| media* | ReactNode | — | What shows through the letters, then fills the screen: a muted looping video, usually. |
| backdrop | ReactNode | — | The sky the mark stands against, behind everything. |
| className | string | — | |
| cue | ReactNode | — | At the foot of the opening frame, fading with the intro: a scroll cue. |
| geometry | MaskGeometry | { width: 555, height: 222, focal: { x: 392, y: 125.5 }, clearance: 28, } | |
| intro | ReactNode | — | Over the opening frame, fading out as the flight starts: a kicker line. |
| maskUrl | string | /fast-mask.svg | The white-on-transparent SVG the city shows through. |
| screens | number | 3 | How many screens of scroll the flight takes. |
<ScrollCue />The "scroll" line under an opening frame: a label over a hairline with a falling accent.
| Prop | Type | Default | Notes |
|---|---|---|---|
| label* | ReactNode | — |
Dependencies
npm
- motion@^12.40.0
Registry
- @fastrp/scroll-root
- @fastrp/utils
Source
components/ds/logo-mask-hero.tsxShowHide
'use client';
import {
motion,
useMotionValueEvent,
useReducedMotion,
useScroll,
useTransform,
} from 'motion/react';
import type * as React from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { useScrollRoot } from '@/components/ds/scroll-root';
import { cn } from '@/lib/utils';
// @fast-ds [email protected]
/** The geometry of the mask: the FAST lockup by default (`public/fast-mask.svg`). */
export type MaskGeometry = {
/** The SVG's viewBox size. */
width: number;
height: number;
/** The point the camera flies through, in viewBox units: the centre of the bolt's widest part. */
focal: { x: number; y: number };
/** That point's distance to the shape's nearest edge, in viewBox units. */
clearance: number;
};
export const FAST_MASK: MaskGeometry = {
width: 555,
height: 222,
focal: { x: 392, y: 125.5 },
clearance: 28,
};
export type LogoMaskHeroProps = {
/** What shows through the letters, then fills the screen: a muted looping video, usually. */
media: React.ReactNode;
/** The sky the mark stands against, behind everything. */
backdrop?: React.ReactNode;
/** Over the opening frame, fading out as the flight starts: a kicker line. */
intro?: React.ReactNode;
/** At the foot of the opening frame, fading with the intro: a scroll cue. */
cue?: React.ReactNode;
/** The headline, the way in and the links — they come up once the city fills the screen. */
children: React.ReactNode;
/** The white-on-transparent SVG the city shows through. */
maskUrl?: string;
geometry?: MaskGeometry;
/** How many screens of scroll the flight takes. */
screens?: number;
className?: string;
};
/** Beats, as a share of the stage's scroll. */
const ZOOM_END = 0.68;
const CONTENT_IN: [number, number] = [0.66, 0.84];
const clamp01 = (value: number) => Math.min(1, Math.max(0, value));
const lerp = (from: number, to: number, amount: number) => from + (to - from) * amount;
/**
* Sizes and places the mask for a scroll position, in real pixels measured off the stage — so it
* is right at any scale, the FiveM NUI's included. The zoom runs on a log scale so the flight
* feels even, and the focal point drifts to the centre as it grows, so the camera ends inside it.
*/
function placeMask(
stage: HTMLElement,
shadow: HTMLElement | null,
progress: number,
geometry: MaskGeometry,
) {
const vw = stage.clientWidth;
const vh = stage.clientHeight;
if (!vw || !vh) return;
const { width: logoW, height: logoH, focal, clearance } = geometry;
const startW = Math.min(vw * 0.86, 1400);
const endW = (Math.hypot(vw, vh) / 2 / clearance) * logoW * 1.1;
const t = clamp01(progress / ZOOM_END) ** 1.35;
const width = startW * (endW / startW) ** t;
const height = (width * logoH) / logoW;
const startFx = vw / 2 + (focal.x / logoW - 0.5) * startW;
const startFy = vh / 2 + (focal.y / logoH - 0.5) * ((startW * logoH) / logoW);
const settle = Math.min(1, t * 1.5);
const eased = settle * settle * (3 - 2 * settle);
const x = lerp(startFx, vw / 2, eased) - (focal.x / logoW) * width;
const y = lerp(startFy, vh / 2, eased) - (focal.y / logoH) * height;
const size = `${width}px ${height}px`;
const position = `${x}px ${y}px`;
stage.style.setProperty('mask-size', size);
stage.style.setProperty('-webkit-mask-size', size);
stage.style.setProperty('mask-position', position);
stage.style.setProperty('-webkit-mask-position', position);
// The mark's shadow on the sky follows it, and drops away once the flight is under way — a blur
// that size would cost every frame for nothing.
if (shadow) {
shadow.style.transform = 'none';
shadow.style.left = `${x}px`;
shadow.style.top = `${y + height * 0.03}px`;
shadow.style.width = `${width}px`;
shadow.style.height = `${height}px`;
shadow.style.visibility = t > 0.25 ? 'hidden' : 'visible';
}
}
/**
* The first screen, told by scrolling: a giant mark with the city showing through its letters;
* scrolling flies the camera into the bolt until the footage fills the screen, and the content
* comes up over it. The stage is sticky and the flight is the reader's own scroll, so it plays
* under reduced motion too — only the content's rise is dropped.
*/
function LogoMaskHero({
media,
backdrop,
intro,
cue,
children,
maskUrl = '/fast-mask.svg',
geometry = FAST_MASK,
screens = 3,
className,
}: LogoMaskHeroProps) {
const reduceMotion = useReducedMotion();
const root = useScrollRoot();
const sectionRef = useRef<HTMLElement>(null);
const maskRef = useRef<HTMLDivElement>(null);
const shadowRef = useRef<HTMLImageElement>(null);
const { scrollYProgress } = useScroll({
container: root,
target: sectionRef,
offset: ['start start', 'end end'],
});
const update = useCallback(
(progress: number) => {
if (maskRef.current) placeMask(maskRef.current, shadowRef.current, progress, geometry);
},
[geometry],
);
useMotionValueEvent(scrollYProgress, 'change', update);
useEffect(() => {
const stage = maskRef.current;
if (!stage) return;
update(scrollYProgress.get());
const observer = new ResizeObserver(() => update(scrollYProgress.get()));
observer.observe(stage);
return () => observer.disconnect();
}, [scrollYProgress, update]);
const shotScale = useTransform(scrollYProgress, [0, ZOOM_END], [1.3, 1]);
const introOpacity = useTransform(scrollYProgress, [0, 0.12], [1, 0]);
const shadeOpacity = useTransform(scrollYProgress, [ZOOM_END - 0.15, CONTENT_IN[1]], [0, 1]);
const contentOpacity = useTransform(scrollYProgress, CONTENT_IN, [0, 1]);
const contentRest = useTransform(scrollYProgress, CONTENT_IN, [reduceMotion ? 0 : 1, 0]);
const contentEvents = useTransform(contentOpacity, (value) => (value > 0.5 ? 'auto' : 'none'));
const mask = `url(${maskUrl})`;
return (
<section
ref={sectionRef}
data-slot="logo-mask-hero"
style={{ height: `calc(var(--scroll-root-h, 100vh) * ${screens})` }}
className={cn('relative bg-ink', className)}
>
<div className="sticky top-0 h-[var(--scroll-root-h,100vh)] overflow-hidden supports-[height:100svh]:h-[var(--scroll-root-h,100svh)]">
{backdrop && (
<div aria-hidden className="pointer-events-none absolute inset-0">
{backdrop}
</div>
)}
<img
ref={shadowRef}
src={maskUrl}
alt=""
aria-hidden
className="pointer-events-none absolute left-1/2 top-1/2 w-[min(86vw,87.5rem)] max-w-none -translate-x-1/2 -translate-y-1/2 opacity-70 [filter:brightness(0)_blur(1.25rem)]"
/>
<div
ref={maskRef}
className="absolute inset-0"
style={{
maskImage: mask,
WebkitMaskImage: mask,
maskRepeat: 'no-repeat',
WebkitMaskRepeat: 'no-repeat',
maskPosition: 'center',
WebkitMaskPosition: 'center',
}}
>
<motion.div className="absolute inset-0 [&>*]:size-full" style={{ scale: shotScale }}>
{media}
</motion.div>
{/* Keeps the content legible once the footage fills the screen. */}
<motion.div
style={{ opacity: shadeOpacity }}
className="pointer-events-none absolute inset-0 bg-black/45 md:bg-gradient-to-r md:from-black/80 md:via-black/35 md:to-transparent"
/>
</div>
{intro && (
<motion.div
style={{ opacity: introOpacity }}
className="pointer-events-none absolute inset-x-0 top-[14%] flex justify-center px-6"
>
{intro}
</motion.div>
)}
{cue && (
<motion.div
style={{ opacity: introOpacity }}
className="pointer-events-none absolute inset-x-0 bottom-10 flex flex-col items-center gap-3"
>
{cue}
</motion.div>
)}
<motion.div
style={
{
opacity: contentOpacity,
pointerEvents: contentEvents,
'--rest': contentRest,
transform: 'translate3d(0, calc(var(--rest) * 32 * var(--ui-px, 1px)), 0)',
} as unknown as React.CSSProperties
}
className="absolute inset-0 z-raised flex flex-col"
>
{children}
</motion.div>
</div>
</section>
);
}
/** The "scroll" line under an opening frame: a label over a hairline with a falling accent. */
function ScrollCue({ label }: { label: React.ReactNode }) {
return (
<>
<span className="text-xs font-semibold uppercase tracking-[0.35em] text-foreground/85 [text-shadow:0_1px_0.75rem_rgb(0_0_0/0.6)]">
{label}
</span>
<span className="relative h-10 w-px overflow-hidden bg-foreground/15">
<span className="absolute inset-x-0 top-0 h-1/2 animate-scroll-cue bg-primary" />
</span>
</>
);
}
export { LogoMaskHero, ScrollCue };assets/fast-mask.svgShowHide
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 555 222"><path fill="#fff" d="M19.3984 51.9551H47.4179L23.7091 200.672H19.3984V51.9551Z"/><path fill="#fff" d="M8.62136 51.9551H0V200.673H8.62136V51.9551Z"/><path fill="#fff" d="M464.246 96.8477L386.1 96.0705C384.167 96.0512 382.936 93.985 383.837 92.2693L428.08 8.00346C429.41 5.46989 426.261 2.9592 424.104 4.83308L288.138 122.91C286.352 124.462 287.412 127.408 289.775 127.461L361.107 129.052C362.966 129.093 364.176 131.034 363.398 132.728L326.097 213.992C324.941 216.511 328.008 218.849 330.115 217.055L465.896 101.415C467.719 99.8632 466.638 96.8713 464.246 96.8477Z"/><path fill="#fff" d="M437.348 137.299C439.014 135.867 440.658 134.411 442.289 132.926C444.81 130.629 447.342 128.383 449.973 126.211C455.734 121.561 461.314 116.78 466.781 111.785L469.289 109.426C469.592 109.155 469.887 108.895 470.174 108.643C471.543 107.438 472.298 106.525 473.108 105.2C474.33 103.201 474.806 100.841 474.727 98.5C474.581 97.2139 474.448 96.3204 473.835 95.0423C472.97 93.2353 471.438 91.8199 469.639 90.9377C467.553 89.9148 465.558 89.3412 462.872 89.0276L459.09 88.9944C456.896 88.9612 454.701 88.9339 452.506 88.9241C450.942 88.9182 449.379 88.9065 447.815 88.8889C443.711 88.8401 439.607 88.7991 435.502 88.7737C431.252 88.7464 427.001 88.7073 422.75 88.6624C414.482 88.5745 406.213 88.5022 397.944 88.4436L395.289 88.4261L397.57 84.0628C401.069 77.3675 404.568 70.6721 408.223 64.0589C410.505 59.9528 412.759 55.832 414.992 51.6992H555L547.758 97.22H503.479L487.133 200.676H427.335L437.348 137.299ZM336.549 173.966C335.557 176.095 334.56 178.221 333.554 180.344C332.298 182.994 331.048 185.647 329.799 188.301L327.352 193.489L327.287 193.623C326.158 195.973 325.03 198.32 323.919 200.676H281.105L277.795 177.295H227.101L215.721 200.676H155.923L237.861 51.6992H309.039L317.163 87.8071C314.835 89.7975 312.543 91.8289 310.289 93.9261C308.489 95.6018 306.685 97.2561 304.847 98.8772C303.749 99.844 302.638 100.799 301.508 101.739C299.703 103.192 297.904 104.649 296.109 106.114L293.289 108.426C292.573 109.003 291.832 109.581 291.082 110.166C287.159 113.225 282.984 116.481 280.769 120.836C280.388 121.584 280.174 122.405 280.095 123.24C279.824 126.094 280.097 128.422 281.386 130.905C281.925 131.944 282.76 132.799 283.742 133.436C285.812 134.78 287.607 135.471 290.396 135.864L293.916 135.951L297.357 136.051C298.251 136.074 299.146 136.096 300.041 136.114C301.497 136.143 302.952 136.176 304.408 136.215C308.227 136.324 312.046 136.422 315.865 136.508C319.821 136.596 323.776 136.696 327.733 136.799C327.885 136.803 328.037 136.807 328.189 136.811L336.549 173.966ZM35.3125 200.676L58.9005 51.6992H215.534L190.747 97.22H111.456L108.353 116.256H178.773L157.478 154.742H102.352L95.1103 200.676H35.3125ZM241.378 142.741H274.691L266.828 91.8403H266.415L241.378 142.741Z"/></svg>