Róbate este efecto de video boomerang (con el prompt incluido)
El problema que resuelve
Los videos de fondo en bucle siempre tienen ese molesto corte al reiniciar, aquí uno que no.
El prompt para copiar
Optimizado con
### Stack
- **Vite** + **React 18** + **TypeScript**
- **Tailwind CSS 3.4**
- **lucide-react** for icons (`LogIn`, `UserPlus`, `Play`, `Sparkles`, `Menu`, `X`)
- No Framer Motion -- all animations are CSS `transition-*` classes
---
### Fonts (loaded in `index.html`)
```html
```
Body/root font stack (in `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 URL (CloudFront)
```
https://d8j0ntlcm91z4.cloudfront.net/user_38xzZboKViGWJOttwIXH07lWA1P/hf_20260511_131941_d136af49-e243-493a-be14-6ff3f24e09e6.mp4
```
---
### Color 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` |
---
### Architecture
Two files:
1. **`BoomerangVideoBg.tsx`** -- captures video frames into canvas, then plays them forward/backward in a seamless boomerang loop at 30fps (960px max capture width).
2. **`App.tsx`** -- the full hero section.
---
### `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(null);
const displayCanvasRef = useRef(null);
const [framesReady, setFramesReady] = useState(false);
const framesRef = useRef([]);
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 cancelAnimationFrame(rafId);
}, [framesReady]);
return (
);
}
```
---
### `App.tsx` (exact)
```tsx
import { useState, useEffect } from 'react';
import { LogIn, UserPlus, Play, Sparkles, Menu, X } from 'lucide-react';
import BoomerangVideoBg from './BoomerangVideoBg';
const BG_VIDEO =
'https://d8j0ntlcm91z4.cloudfront.net/user_38xzZboKViGWJOttwIXH07lWA1P/hf_20260511_131941_d136af49-e243-493a-be14-6ff3f24e09e6.mp4';
function App() {
const [menuOpen, setMenuOpen] = useState(false);
useEffect(() => {
if (menuOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [menuOpen]);
const navLinks = [
{ href: '#mission', label: 'Purpose' },
{ href: '#how', label: 'The Process' },
{ href: '#pricing', label: 'Tariffs' },
];
return (
LinkFlowTM
{navLinks.map((link, i) => (
{link.label}
))}
Try it Live
Sign Me Up!
Enter
setMenuOpen((v) => !v)}
className="lg:hidden 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"
aria-label={menuOpen ? 'Close menu' : 'Open menu'}
aria-expanded={menuOpen}
>
{/* Mobile menu overlay */}
setMenuOpen(false)}
>
{/* Mobile menu drawer */}
{navLinks.map((link, i) => (
setMenuOpen(false)}
className={`text-2xl font-semibold text-[#1f2a1d] py-4 border-b border-[#1f2a1d]/10 transition-all duration-500 ${
menuOpen ? 'translate-x-0 opacity-100' : 'translate-x-8 opacity-0'
}`}
style={{ transitionDelay: menuOpen ? `${150 + i * 70}ms` : '0ms' }}
>
{link.label}
))}
Sign Me Up!
Enter
Try it Live
{/* Hero copy */}
Close the rift{' '}
linking
signals and action
Shape scattered signals into meaningful outcomes via AI-driven workflows.
{/* Bottom-left CTA block */}
FluxEngineTM
LinkFlow smoothly unites your company systems, streamlining data paths between services without having to write custom scripts.
Try it Live
Know More.
{/* Bottom-right video link */}
How we build?
1:35
);
}
export default App;
```
---
### Animation Details (all CSS, no Framer Motion)
| Element | Property | Values |
|---------|----------|--------|
| Hamburger Menu/X icon swap | `transition-all duration-300` | Open: Menu gets `opacity-0 rotate-90 scale-50`, X gets `opacity-100 rotate-0 scale-100`. Closed: reverse. |
| Mobile overlay backdrop | `transition-opacity duration-300` | Open: `opacity-100 pointer-events-auto`. Closed: `opacity-0 pointer-events-none`. |
| Mobile drawer slide | `transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]` | Open: `translate-x-0`. Closed: `translate-x-full`. |
| Mobile nav links stagger | `transition-all duration-500` | Open: `translate-x-0 opacity-100`, delay per item: `150ms + i * 70ms`. Closed: `translate-x-8 opacity-0`, delay `0ms`. |
| Mobile CTA group | `transition-all duration-500` | Open: `translate-x-0 opacity-100`, delay `400ms`. Closed: `translate-x-8 opacity-0`, delay `0ms`. |
| Nav buttons | `transition-colors` | Default Tailwind duration (150ms). |
| Opacity links | `transition-opacity` | `hover:opacity-80`. |
---
### Key Layout/Spacing Notes
- Root section: `relative w-full min-h-screen sm:h-screen overflow-hidden`
- Navbar padding: `px-4 sm:px-6 md:px-10 py-4 sm:py-6`
- Desktop pill nav: `bg-white/70 backdrop-blur-md rounded-full pl-6 pr-1 py-1 shadow-sm border border-white/60`
- Hero heading: `pt-24 sm:pt-28 md:pt-32`, font sizes `text-[2rem] sm:text-4xl md:text-5xl lg:text-[4.75rem] xl:text-[5.25rem]`, `leading-[0.95]`, `letterSpacing: '-0.035em'`
- 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`
- Bottom-right video: `absolute right-6 md:right-10 bottom-8 md:bottom-10`
---
### Dependencies (package.json)
```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"
}
}
```
La mayoría de los videos de fondo en bucle tienen una unión evidente: el clip se reinicia y se ve un corte brusco. Esta técnica evita eso por completo capturando el video cuadro por cuadro y reproduciendo esos cuadros hacia adelante, luego hacia atrás, luego hacia adelante de nuevo, así el punto de bucle se vuelve invisible. La nav de vidrio flotante y el panel deslizante móvil con enlaces que aparecen uno por uno completan un hero que realmente se ve premium, no otra plantilla de video estático con overlay.
Qué vale la pena robar
La técnica de captura boomerang en sí, funciona con cualquier video, incluso los que nunca fueron pensados para hacer bucle.
Una nav tipo píldora liquid-glass que se colapsa en un verdadero panel deslizante en móvil, no solo un menú acordeón básico.
Hacer que los enlaces de nav móvil aparezcan uno por uno al abrir se ve mucho más cuidado que un menú que aparece todo de golpe.
El prompt de abajo es deliberadamente genérico: cambia el nombre del producto, el texto y el video por los tuyos. El código del componente es una técnica pura, sin branding incluido.