LinkFlow
Full-bleed hero whose clip is captured frame by frame and replayed forward-then-reverse on canvas, under a frosted pill nav, a two-tone green headline and a slide-in mobile drawer built entirely from CSS transitions.
Prompt
# LinkFlow — Boomerang Video Hero
Build a full-bleed hero for **LinkFlow**, an integration platform. The background clip plays forward once while every frame is captured to canvas, then loops forward-and-back at 30fps. Over it sit a frosted pill nav, a two-tone green headline, a bottom-left product block and a bottom-right video link, plus a slide-in drawer below `lg`.
Reproduce this exactly — the two files below are the deliverable. Same class strings, same hex values, same copy.
## Stack
- **Vite** + **React 18** + **TypeScript**
- **Tailwind CSS 3.4**
- **lucide-react** for icons (`LogIn`, `UserPlus`, `Play`, `Sparkles`, `Menu`, `X`)
- No Framer Motion — every animation is a CSS `transition-*` class
```json
{
"dependencies": {
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.18",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.5.3",
"vite": "^5.4.2"
}
}
```
## Fonts — `index.html`
```html
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<link href="https://db.onlinewebfonts.com/c/6e47ef470dd19698c911332a9b4d1cf4?family=Neue+Haas+Grotesk+Text+Pro" rel="stylesheet" />
<link href="https://db.onlinewebfonts.com/c/dec0d9b4e22ca588dc20e1e2e09a59b5?family=Neue+Haas+Grotesk+Display+Pro+55+Roman" rel="stylesheet" />
```
`index.css`:
```css
html, body, #root {
height: 100%;
margin: 0;
font-family: 'Neue Haas Grotesk Display Pro 55 Roman', 'Neue Haas Grotesk Text Pro', 'Helvetica Neue', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
```
## Video (use this URL exactly)
```
https://d8j0ntlcm91z4.cloudfront.net/user_38xzZboKViGWJOttwIXH07lWA1P/hf_20260511_131941_d136af49-e243-493a-be14-6ff3f24e09e6.mp4
```
A slow push-in over a mossy natural arch spanning misty karst peaks: pale cream sky across the top two thirds, dark green foliage down the sides and along the bottom.
## Colour palette
| Token | Hex |
|-------|-----|
| Dark green (text, buttons) | `#1f2a1d` |
| Medium dark green | `#2d3a2a` |
| Button hover | `#2a3827` |
| Body text green | `#4b5b47` |
| Heading primary | `#336443` |
| Heading accent | `#85AB8B` |
| Bottom-left text | `#3d5638` |
| Bottom-left button bg | `#3d5638`, hover `#2d4228` |
## `BoomerangVideoBg.tsx` (exact)
```tsx
import { useEffect, useRef, useState } from 'react';
type Props = {
src: string;
className?: string;
};
export default function BoomerangVideoBg({ src, className }: Props) {
const videoRef = useRef<HTMLVideoElement>(null);
const displayCanvasRef = useRef<HTMLCanvasElement>(null);
const [framesReady, setFramesReady] = useState(false);
const framesRef = useRef<HTMLCanvasElement[]>([]);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
const frames: HTMLCanvasElement[] = [];
let capturing = true;
let lastTime = -1;
const MAX_WIDTH = 960;
const captureFrame = () => {
if (!capturing || video.readyState < 2) return;
if (video.currentTime === lastTime) return;
lastTime = video.currentTime;
const vw = video.videoWidth;
const vh = video.videoHeight;
if (!vw || !vh) return;
const scale = Math.min(1, MAX_WIDTH / vw);
const w = Math.round(vw * scale);
const h = Math.round(vh * scale);
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.drawImage(video, 0, 0, w, h);
frames.push(canvas);
};
type VFCVideo = HTMLVideoElement & {
requestVideoFrameCallback?: (cb: () => void) => number;
};
const vfcVideo = video as VFCVideo;
const hasVFC = typeof vfcVideo.requestVideoFrameCallback === 'function';
let rafId = 0;
const rafLoop = () => {
captureFrame();
if (capturing) rafId = requestAnimationFrame(rafLoop);
};
const vfcLoop = () => {
captureFrame();
if (capturing && vfcVideo.requestVideoFrameCallback) {
vfcVideo.requestVideoFrameCallback(vfcLoop);
}
};
const onEnded = () => {
capturing = false;
if (frames.length > 0) {
framesRef.current = frames;
setFramesReady(true);
}
};
const onLoaded = () => {
video.play().catch(() => {});
if (hasVFC) {
vfcVideo.requestVideoFrameCallback!(vfcLoop);
} else {
rafId = requestAnimationFrame(rafLoop);
}
};
video.addEventListener('loadedmetadata', onLoaded);
video.addEventListener('ended', onEnded);
if (video.readyState >= 1) onLoaded();
return () => {
capturing = false;
cancelAnimationFrame(rafId);
video.removeEventListener('loadedmetadata', onLoaded);
video.removeEventListener('ended', onEnded);
};
}, [src]);
useEffect(() => {
if (!framesReady) return;
const canvas = displayCanvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const frames = framesRef.current;
if (frames.length === 0) return;
const first = frames[0];
canvas.width = first.width;
canvas.height = first.height;
let index = 0;
let direction = 1;
let last = performance.now();
const interval = 1000 / 30;
let rafId = 0;
const render = (now: number) => {
if (now - last >= interval) {
last = now;
ctx.drawImage(frames[index], 0, 0);
index += direction;
if (index >= frames.length - 1) {
index = frames.length - 1;
direction = -1;
} else if (index <= 0) {
index = 0;
direction = 1;
}
}
rafId = requestAnimationFrame(render);
};
rafId = requestAnimationFrame(render);
return () => cancelAnimationFrame(rafId);
}, [framesReady]);
return (
<div className={className ?? 'absolute inset-0 w-full h-full'}>
<video
ref={videoRef}
src={src}
className="w-full h-full object-cover"
style={{ display: framesReady ? 'none' : 'block' }}
muted
playsInline
preload="auto"
crossOrigin="anonymous"
/>
<canvas
ref={displayCanvasRef}
className="w-full h-full object-cover"
style={{ display: framesReady ? 'block' : 'none' }}
/>
</div>
);
}
```
## `App.tsx` — structure
Root: `<section className="relative w-full min-h-screen sm:h-screen overflow-hidden">`, holding `<BoomerangVideoBg src={BG_VIDEO} className="absolute inset-0 w-full h-full" />` and everything below.
`menuOpen` state locks `document.body.style.overflow` while the drawer is open, and resets it on unmount.
`navLinks`: `{ href: '#mission', label: 'Purpose' }`, `{ href: '#how', label: 'The Process' }`, `{ href: '#pricing', label: 'Tariffs' }`.
**Nav** — `absolute top-0 left-0 right-0 z-30 flex items-center justify-between px-4 sm:px-6 md:px-10 py-4 sm:py-6`:
- Wordmark `LinkFlow` with `<sup className="text-[10px] sm:text-xs font-medium">TM</sup>`, in `text-lg sm:text-xl md:text-2xl font-semibold tracking-tight`, wrapper `text-[#2d3a2a]`
- Desktop pill (`hidden lg:flex`): `items-center gap-1 bg-white/70 backdrop-blur-md rounded-full pl-6 pr-1 py-1 shadow-sm border border-white/60`. Each link `text-sm px-3 py-2 transition-colors`; index 0 is `font-semibold text-[#1f2a1d]`, the rest `font-medium text-[#4b5b47] hover:text-[#1f2a1d]`. Then a `Try it Live` button: `ml-2 bg-[#1f2a1d] hover:bg-[#2a3827] text-white text-sm font-medium px-5 py-2.5 rounded-full transition-colors`
- Right cluster `flex items-center gap-3 sm:gap-6 text-[#2d3a2a]`: `Sign Me Up!` (`UserPlus`) and `Enter` (`LogIn`), both `hidden sm:flex items-center gap-2 text-sm font-medium hover:opacity-80 transition-opacity`; then the `lg:hidden` hamburger — `relative flex items-center justify-center w-10 h-10 rounded-full bg-white/70 backdrop-blur-md border border-white/60 text-[#1f2a1d] transition-all duration-300 hover:bg-white/90`, with `aria-label` toggling between `Open menu` / `Close menu` and `aria-expanded={menuOpen}`. `Menu` and `X` are stacked `absolute` and cross-fade: open → `Menu` gets `opacity-0 rotate-90 scale-50`, `X` gets `opacity-100 rotate-0 scale-100`; closed → reversed. Both `w-5 h-5 transition-all duration-300`.
**Mobile overlay** — `lg:hidden fixed inset-0 z-20 transition-opacity duration-300`, `opacity-100 pointer-events-auto` when open else `opacity-0 pointer-events-none`, `onClick` closes. Inside: `absolute inset-0 bg-[#1f2a1d]/40 backdrop-blur-sm`.
**Mobile drawer** — `lg:hidden fixed top-0 right-0 bottom-0 z-20 w-[85%] max-w-sm bg-white/95 backdrop-blur-xl shadow-2xl transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]`, `translate-x-0` when open else `translate-x-full`. Inner `flex flex-col h-full pt-24 px-8 pb-8`:
- The three nav links, `text-2xl font-semibold text-[#1f2a1d] py-4 border-b border-[#1f2a1d]/10 transition-all duration-500`, open → `translate-x-0 opacity-100`, closed → `translate-x-8 opacity-0`, with `transitionDelay` of `${150 + i * 70}ms` when open and `0ms` when closed
- A CTA group `mt-8 flex flex-col gap-4 transition-all duration-500` with the same open/closed classes and a `400ms` delay: `Sign Me Up!` and `Enter` (both `sm:hidden`), then `Try it Live` (`mt-2 bg-[#1f2a1d] hover:bg-[#2a3827] text-white text-sm font-semibold px-5 py-3 rounded-full transition-colors`)
**Hero copy** — `relative z-10 flex flex-col items-center text-center pt-24 sm:pt-28 md:pt-32 px-4 sm:px-6`:
- `<h1>` — `font-normal leading-[0.95] text-[#336443] text-[2rem] sm:text-4xl md:text-5xl lg:text-[4.75rem] xl:text-[5.25rem] max-w-5xl`, inline `fontFamily` repeating the display stack and `letterSpacing: '-0.035em'`. Text: `Close the rift ` then a `<span className="text-[#85AB8B]">` holding `linking`, a `<br className="hidden sm:block" />`, and ` signals and action`
- `<p>` — `mt-6 sm:mt-8 text-[#4b5b47] text-sm sm:text-base md:text-lg leading-relaxed max-w-md px-2`: *Shape scattered signals into meaningful outcomes via AI-driven workflows.*
**Bottom-left block** — `absolute left-4 right-4 sm:right-auto sm:left-6 md:left-10 bottom-6 sm:bottom-8 md:bottom-10 z-10 max-w-sm`:
- Label row `flex items-center gap-2 text-[#3d5638] sm:text-white/95 mb-3` with `Sparkles` and `FluxEngine` + `<sup className="text-[10px]">TM</sup>`, `text-sm font-semibold sm:font-medium`
- Body `text-[#3d5638]/90 sm:text-white/85 text-xs leading-relaxed mb-6 max-w-xs font-medium sm:font-normal`: *LinkFlow smoothly unites your company systems, streamlining data paths between services without having to write custom scripts.*
- Buttons row `flex items-center gap-4 flex-wrap`: `Try it Live` (`bg-[#3d5638] sm:bg-white hover:bg-[#2d4228] sm:hover:bg-white/90 text-white sm:text-[#1f2a1d] text-sm font-semibold px-5 sm:px-6 py-2.5 sm:py-3 rounded-full transition-colors shadow-sm`) and `Know More.` (`text-[#3d5638] sm:text-white text-sm font-semibold sm:font-medium hover:opacity-80 transition-opacity`)
**Bottom-right video link** — `hidden sm:flex absolute right-6 md:right-10 bottom-8 md:bottom-10 z-10 items-center gap-2 text-white/90 text-sm`: a 24px `bg-white/20 backdrop-blur-sm hover:bg-white/30` circle with a `Play` glyph (`w-3 h-3 fill-white text-white ml-0.5`), then `How we build?` (`font-medium`) and `1:35` (`text-white/60`).
## Quality Bar
- **The display font's stylesheet is a 404, and it is the first name in every stack.** `https://db.onlinewebfonts.com/c/dec0d9b4e22ca588dc20e1e2e09a59b5?...` returns **HTTP 404** with the body `Web Fonts : V1.0 [Type] -> Error [Data] -> Access Deny ! No Actions`. `Neue Haas Grotesk Display Pro 55 Roman` therefore never loads — not in `index.css`, not in the `<h1>`'s inline `fontFamily` — and every heading silently falls through to Text Pro and then Helvetica Neue. The page is named after a display face it never renders.
- **The font that does load is a licensed Linotype face being redistributed by a mirror.** The Text Pro woff2 decodes to a name table reading manufacturer `Linotype GmbH`, designer `Christian Schwartz`, and a licence description that begins: *"Microsoft supplied font. You may use this font to create, display, and print content as permitted by the license terms or terms of use, of the Microsoft product, service, or content in which this font was included."* Re-serving it from `db.onlinewebfonts.com` to arbitrary websites is not that. License and self-host it, or fall back to the `Helvetica Neue, Helvetica, Arial` the stack already names.
- **Inter is loaded and never used.** Five weights plus two `preconnect`s, and nothing in `index.css` or either component names Inter — the font stack is Neue Haas end to end. Google's latin slice is **48,432 bytes** fetched on the critical path and never drawn. Delete the link.
- **The frame bank is 384 MiB of live canvases and nothing ever frees it.** The clip is 3828×2164, so `MAX_WIDTH = 960` scales each capture to 960×543; that is 960 × 543 × 4 = 2,085,120 bytes per canvas, and 193 frames makes **402,428,160 bytes — 383.8 MiB**, parked in `framesRef` for the life of the component. The cleanup sets `capturing = false` and cancels the rAF but never releases the canvases. Store `ImageBitmap`s, drop the cap to what actually paints, and zero the canvases on unmount.
- **The 4K source stops mattering after eight seconds.** 16,662,233 bytes at 3828×2164 are downloaded and decoded, and then `ended` fires and the background becomes a 960-wide canvas upscaled to fill — 1594px at 1440×900, a 1.66× upscale. Everything above 960 is thrown away by the design's own cap. Re-encoded at 960×542 the same clip is **436,777 bytes, 2.6% of the original**. There is also no `poster`, so the first paint is blank.
- **`currentTime` cannot identify a frame, and the type signature throws away the value that can.** `requestVideoFrameCallback?: (cb: () => void) => number` discards the callback's `metadata` argument, where `mediaTime` — the presentation time of the frame you were just handed — lives. `video.currentTime` is the playback clock and keeps moving between callbacks, so the dedupe guard both misses repeats and has no way to detect drops.
- **`onLoaded` can start two capture loops.** It is registered on `loadedmetadata` *and* invoked directly when `readyState >= 1`. For an already-buffered or cached video both paths can run, and each starts its own rVFC or rAF loop pushing into the same `frames` array. Guard it with a `started` flag.
- **The cleanup does not stop the rVFC loop.** `cancelAnimationFrame(rafId)` only covers the fallback path — `rafId` is never assigned when `hasVFC` is true. Only `capturing = false` ends the video-frame loop, and it does so one callback late. There is no `cancelVideoFrameCallback` call.
- **A background tab starves the bank, and the design gives it no second chance.** Both capture loops are throttled to a crawl while `document.visibilityState` is `hidden`, and the hand-off is gated on a single `ended` event that arrives exactly once. Open the page in a background tab and it ping-pongs a handful of frames forever. Since the `<video>` has no `loop`, the alternative failure — zero frames captured — leaves it frozen on its last frame, which at least is not a blank background, but nothing tells you either happened.
- **Half the headline never clears 3:1, and the other half proves it did not have to.** Measured across all 193 frames at 1440×900: the `#85AB8B` accent carrying `linking signals and action` bottoms out at **1.18:1** with **100% of its pixels below 3:1 in every single frame** — it is a pale sage set on a pale cream sky. The `#336443` half of the very same `<h1>` measures **5.48:1** at its worst and never fails. Darken the accent or drop the two-tone.
- **Every white-on-video block fails too, and the mobile branch already knows better.** Same 193 frames: the bottom-left label at `white/95` worst **1.41:1** (11.55% of pixels under 4.5:1), its 12px body at `white/85` worst **1.30:1** (11.80%), and the bottom-right `How we build?` row at `white/90` worst **1.31:1** (13.12%). The hero paragraph in `#4b5b47` is milder but still fails in 109 of 193 frames — worst 3.23:1, 4.56% of pixels. Note that below `sm` these same elements are specified in `#3d5638` dark green; it is the `sm:` branch that switches them to white, onto footage whose bottom third is bright mist.
- **Six controls stay focusable inside the closed drawer.** Measured with `aria-expanded="false"` at 390×844: `Purpose`, `The Process`, `Tariffs`, `Sign Me Up!`, `Enter` and `Try it Live` all accept focus, all report `visibility: visible`, and all sit at x = 454 — entirely off-screen to the right. Three of them are still at `opacity: 1`. Closing the drawer only translates it; add `inert` (or `visibility: hidden` at the end of the transition) so keyboard users are not tabbed into a panel they cannot see.
- **The drawer is not a dialog.** No `role="dialog"`, no `aria-modal`, no focus trap, no Escape handler — the backdrop closes on click only. And the nav is `z-30` against the drawer's and backdrop's `z-20`, so the "modal" slides *underneath* the header and the scrim never dims the wordmark.
- **Every control has a hover state and none has a focus state.** Thirteen focusable controls sit over moving footage with no `:focus-visible` rule anywhere in the spec.
- **There is no `prefers-reduced-motion` branch.** A full-screen video ping-ponging forever is exactly what that setting asks you to stop, and the drawer adds a 500ms slide with staggered children. Freeze on a captured frame rather than falling back to the video, which has its own motion.
- **`<sup>TM</sup>` is read aloud as "T M".** It appears twice — `LinkFlow` and `FluxEngine`. Use the `™` character, or keep the markup and give it an accessible name.
- **The layout has no room to give and nothing to scroll.** `sm:h-screen` resolves to the large viewport on mobile Safari — the height measured with the toolbar retracted — and `scrollHeight - innerHeight` measured **0** at every size tested, so anything that overflows is simply gone. The hero copy is in flow from the top while the bottom-left block is pinned to the bottom: measured at 1280 wide, the gap between them is 118px at 900px tall, 18px at 600px tall, and by 560px they interleave vertically (they stay clear only because they are in different columns). Use `svh`/`dvh` and let the page scroll.
- **The active nav item is chosen by array index.** `i === 0` hard-codes `Purpose` as current; it also carries no `aria-current`. And all three hrefs (`#mission`, `#how`, `#pricing`) have no matching element on a page that is only a hero.
Bunları da beğenebilirsiniz
Vectrus Energy
Scroll-tied cinematic section: a 500vh track scrubs an aerial clip frame by frame through a WebCodecs frame bank, with three sequential copy blocks.
Wanderful
Cinematic travel hero: a full-bleed loop drifting under a GSAP mouse parallax, with a liquid-glass pill navbar and two fade-up copy blocks.
Aurora Weather Dashboard
Liquid-glass weather dashboard on a 1357x871 unit grid: frosted panels over a storm photo and a chart that draws itself, then fills.
Fastshot Landing
Single-screen AI app-builder landing: full-bleed dawn video, glass composer card and a pixel-specified absolute toolbar.