1# Aethera — Cinematic Hero Section with Looping Video Background
2
3## Overview
4
5Build a fullscreen, single-page hero section for a fictional studio brand called "Aethera". The page pairs a monochrome editorial layout (white background, black ink, gray accents) with a looping background video that custom-fades in and out at each playthrough, an editorial serif headline with italicized emphasis, a glassmorphic navigation bar, and a staggered "fade-rise" entrance animation.
6
7## Tech Stack
8
9- **Framework:** React `^18.3.1` with `react-dom` `^18.3.1`
10- **Build tool:** Vite `^6.3.5` with `@vitejs/plugin-react` `^4.4.1`
11- **Language:** TypeScript `^5.8.3`
12- **Styling:** Tailwind CSS `^4.1.7` via the `@tailwindcss/vite` `^4.1.7` plugin (CSS-first config using the `@theme` directive — no `tailwind.config.js`)
13- **Fonts:** Instrument Serif (display) and Inter (body), loaded from Google Fonts
14- **Testing/tooling:** Playwright `^1.60.0` (dev dependency)
15- **Notable techniques:** custom `requestAnimationFrame`-driven video fade loop, CSS keyframe entrance choreography, `prefers-reduced-motion` support, glassmorphic backdrop blur on the navbar
16
17## Global Setup
18
19### Entry HTML (`index.html`)
20
21- `<html lang="en">`, `<meta charset="UTF-8" />`, `<meta name="viewport" content="width=device-width, initial-scale=1.0" />`.
22- Meta description: `Aethera — Beyond silence, we build the eternal. Digital havens for deep work and pure flows.`
23- Preconnect to Google Fonts: `https://fonts.googleapis.com` and `https://fonts.gstatic.com` (the latter with `crossorigin`).
24- Inline SVG favicon (`data:image/svg+xml,...`): a `32×32` rounded rect (`rx='16'`) filled `#000000` with a centered white (`#ffffff`) letter "A" in `Georgia,serif` at `font-size='18'`.
25- Page `<title>`: `Aethera® — Beyond silence, we build the eternal.`
26- Body contains `<div id="root"></div>` and loads `<script type="module" src="/src/main.tsx"></script>`.
27
28### React entry (`src/main.tsx`)
29
30```tsx
31import { StrictMode } from "react";
32import { createRoot } from "react-dom/client";
33import App from "./App";
34import "./index.css";
35
36createRoot(document.getElementById("root")!).render(
37 <StrictMode>
38 <App />
39 </StrictMode>,
40);
41```
42
43### Stylesheet imports (`src/index.css`)
44
45Import order matters — fonts first, then Tailwind, then theme:
46
47```css
48@import "./styles/fonts.css";
49@import "tailwindcss";
50@import "./styles/theme.css";
51```
52
53## Fonts (`src/styles/fonts.css`)
54
55- **Display text** (headings, logo): Instrument Serif (roman + italic, used for headline emphasis).
56- **Body text** (navigation, descriptions, buttons): Inter.
57- Both fonts are imported via a single Google Fonts `@import`:
58
59```css
60@import url("https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Inter:wght@400;500;600&display=swap");
61```
62
63## Color Palette & Theme Tokens (`src/styles/theme.css`)
64
65Define design tokens inside Tailwind's `@theme` block so they are available as utilities (e.g. `bg-background`) and CSS variables:
66
67```css
68@theme {
69 --color-background: #ffffff;
70 --color-ink: #000000;
71 --color-muted: #6f6f6f;
72
73 --font-display: "Instrument Serif", "Georgia", "Times New Roman", serif;
74 --font-body:
75 "Inter", -apple-system, "Segoe UI", "Helvetica Neue", Arial, sans-serif;
76}
77```
78
79Color usage summary:
80
81- **Background:** white (`#ffffff`)
82- **Headlines / logo / buttons:** black (`#000000`)
83- **Descriptions / inactive menu items / italic emphasis words:** gray (`#6f6f6f`)
84- **Button text:** white (`#ffffff` / `#FFFFFF`)
85
86### Base styles
87
88Also in `theme.css`:
89
90```css
91::selection {
92 background: #000000;
93 color: #ffffff;
94}
95
96html {
97 scroll-behavior: smooth;
98}
99
100body {
101 margin: 0;
102 background-color: var(--color-background);
103 color: var(--color-ink);
104 font-family: var(--font-body);
105 -webkit-font-smoothing: antialiased;
106 -moz-osx-font-smoothing: grayscale;
107 text-rendering: optimizeLegibility;
108}
109```
110
111## Layout Structure (`src/App.tsx`)
112
113A single root container holds three stacked layers:
114
115```tsx
116import VideoBackground from "./components/VideoBackground";
117import Navbar from "./components/Navbar";
118import Hero from "./components/Hero";
119
120export default function App() {
121 return (
122 <div className="relative min-h-screen w-full overflow-hidden bg-background">
123 <VideoBackground />
124 <Navbar />
125 <Hero />
126 </div>
127 );
128}
129```
130
131- **Container:** `relative min-h-screen w-full overflow-hidden bg-background`.
132- **Background video layer** sits at `z-0`.
133- **Gradient overlay** is rendered over the video.
134- **Navigation bar** and **hero section** both sit at `z-10`.
135- All elements should be responsive and maintain the glassmorphic aesthetic with the specified padding, positioning, and smooth animations.
136
137## Video Background (`src/components/VideoBackground.tsx`)
138
139### Source
140
141- The video asset is served locally from `/assets/hf_20260328_083109_283f3553-e28f-428b-a723-d639c617eb2b.mp4` (a `VIDEO_SRC` constant). The original asset was sourced from `https://d8j0ntlcm91z4.cloudfront.net/USER_38XZZBOKVIGWJOTTWIXH07LWA1P/HF_20260328_083109_283F3553-E28F-428B-A723-D639C617EB2B.MP4` and has been vendored into the project's `public/assets/` directory (the local filename is lowercased). Note: the CloudFront URL appeared fully uppercased in the original brief; only its scheme+host casing has been normalized here (the case-sensitive path segment and `.MP4` extension are preserved as-is) — the local path is the ground-truth source.
142
143### Positioning
144
145- The `<video>` element uses `position: absolute` with inline style `inset: "auto 0 0 0"` and `top: "300px"`, plus `opacity: 0` (driven by JS) and `willChange: "opacity"`.
146- Classes on the video: `absolute w-full object-cover`.
147- Wrapper: `<div aria-hidden="true" className="absolute inset-0 z-0">`.
148- Video attributes: `muted`, `playsInline`, `autoPlay`, `preload="auto"`.
149
150### Custom fade-in / fade-out loop logic
151
152Implemented with React `useEffect` and `useRef`:
153
154- A `requestAnimationFrame` loop continuously monitors `currentTime` and `duration`.
155- **Fade in** over `0.5s` at the start (opacity `0 → 1`).
156- **Fade out** over `0.5s` before the end (opacity `1 → 0`).
157- On the `ended` event: set opacity to `0`, wait `100ms`, reset `currentTime = 0`, then call `play()` again.
158- This creates a seamless manual loop with smooth fade transitions.
159- Constants: `FADE_SECONDS = 0.5` (fade window applied at both ends), `RESTART_DELAY_MS = 100` (pause between loops before restart).
160
161Reference implementation:
162
163```tsx
164import { useEffect, useRef } from "react";
165
166const VIDEO_SRC =
167 "/assets/hf_20260328_083109_283f3553-e28f-428b-a723-d639c617eb2b.mp4";
168
169/** Fade window, in seconds, applied at both ends of each playthrough. */
170const FADE_SECONDS = 0.5;
171
172/** Pause between loops before the video restarts, in milliseconds. */
173const RESTART_DELAY_MS = 100;
174
175export default function VideoBackground() {
176 const videoRef = useRef<HTMLVideoElement>(null);
177
178 useEffect(() => {
179 const video = videoRef.current;
180 if (!video) return;
181
182 let frameId = 0;
183 let restartTimer: number | undefined;
184
185 const updateOpacity = () => {
186 const { currentTime, duration } = video;
187
188 if (Number.isFinite(duration) && duration > 0) {
189 const fadeIn = currentTime / FADE_SECONDS;
190 const fadeOut = (duration - currentTime) / FADE_SECONDS;
191 const opacity = Math.min(Math.max(Math.min(fadeIn, fadeOut), 0), 1);
192 video.style.opacity = opacity.toFixed(3);
193 }
194
195 frameId = requestAnimationFrame(updateOpacity);
196 };
197
198 const handleEnded = () => {
199 video.style.opacity = "0";
200 restartTimer = window.setTimeout(() => {
201 video.currentTime = 0;
202 video.play().catch(() => {
203 /* autoplay interrupted — the rAF loop keeps the layer hidden */
204 });
205 }, RESTART_DELAY_MS);
206 };
207
208 video.addEventListener("ended", handleEnded);
209 frameId = requestAnimationFrame(updateOpacity);
210
211 video.play().catch(() => {
212 /* Autoplay can be deferred by the browser; muted playback retries
213 automatically once the media is allowed to start. */
214 });
215
216 return () => {
217 cancelAnimationFrame(frameId);
218 window.clearTimeout(restartTimer);
219 video.removeEventListener("ended", handleEnded);
220 };
221 }, []);
222
223 return (
224 <div aria-hidden="true" className="absolute inset-0 z-0">
225 <video
226 ref={videoRef}
227 src={VIDEO_SRC}
228 muted
229 playsInline
230 autoPlay
231 preload="auto"
232 className="absolute w-full object-cover"
233 style={{
234 inset: "auto 0 0 0",
235 top: "300px",
236 opacity: 0,
237 willChange: "opacity",
238 }}
239 />
240 {/* Gradient veil: dissolves the video into the page at both edges. */}
241 <div className="absolute inset-0 bg-gradient-to-b from-background via-transparent to-background" />
242 </div>
243 );
244}
245```
246
247### Gradient overlay
248
249A sibling `<div>` over the video: `absolute inset-0 bg-gradient-to-b from-background via-transparent to-background` — dissolves the video into the page at the top and bottom edges.
250
251## Navigation Bar (`src/components/Navbar.tsx`)
252
253- **Header wrapper:** `<header className="relative z-10 bg-white/70 backdrop-blur-md">` — the glassmorphic blur effect.
254- **Nav element:** `aria-label="Primary"`, classes `mx-auto flex max-w-7xl items-center justify-between px-8 py-6`.
255- **Logo:** `Aethera®` where the registered-trademark symbol (`®`) is rendered as a superscript via `<sup className="text-[0.45em]">`. Logo `<a href="#">` styling: `font-display text-3xl tracking-tight text-[#000000]`.
256- **Menu items** (rendered from a `MENU_ITEMS` array, hidden on small screens via `hidden items-center gap-8 md:flex`):
257 - `Home` — `href="#"`, active, color `text-[#000000]`
258 - `Studio` — `href="#studio"`
259 - `About` — `href="#about"`
260 - `Journal` — `href="#journal"`
261 - `Reach Us` — `href="#reach-us"`
262 - Inactive items: `text-[#6F6F6F] hover:text-[#000000]`. All items: `text-sm transition-colors`. The active item also sets `aria-current="page"`.
263- **CTA button:** label `Begin Journey`, classes `rounded-full bg-[#000000] px-6 py-2.5 text-sm text-white transition-transform duration-300 ease-out hover:scale-103`.
264
265```tsx
266const MENU_ITEMS = [
267 { label: "Home", href: "#", active: true },
268 { label: "Studio", href: "#studio", active: false },
269 { label: "About", href: "#about", active: false },
270 { label: "Journal", href: "#journal", active: false },
271 { label: "Reach Us", href: "#reach-us", active: false },
272];
273
274export default function Navbar() {
275 return (
276 <header className="relative z-10 bg-white/70 backdrop-blur-md">
277 <nav
278 aria-label="Primary"
279 className="mx-auto flex max-w-7xl items-center justify-between px-8 py-6"
280 >
281 <a
282 href="#"
283 className="font-display text-3xl tracking-tight text-[#000000]"
284 >
285 Aethera<sup className="text-[0.45em]">®</sup>
286 </a>
287
288 <ul className="hidden items-center gap-8 md:flex">
289 {MENU_ITEMS.map(({ label, href, active }) => (
290 <li key={label}>
291 <a
292 href={href}
293 aria-current={active ? "page" : undefined}
294 className={`text-sm transition-colors ${
295 active
296 ? "text-[#000000]"
297 : "text-[#6F6F6F] hover:text-[#000000]"
298 }`}
299 >
300 {label}
301 </a>
302 </li>
303 ))}
304 </ul>
305
306 <button
307 type="button"
308 className="rounded-full bg-[#000000] px-6 py-2.5 text-sm text-white transition-transform duration-300 ease-out hover:scale-103"
309 >
310 Begin Journey
311 </button>
312 </nav>
313 </header>
314 );
315}
316```
317
318## Hero Section (`src/components/Hero.tsx`)
319
320### Positioning & layout
321
322- **Section:** `relative z-10 flex flex-col items-center justify-center px-6 pb-40 text-center` with inline `style={{ paddingTop: "calc(8rem - 75px)" }}`.
323
324### Headline
325
326- **Text:** "Beyond *silence,* we build *the eternal.*" — the words `silence,` and `the eternal.` are wrapped in `<em className="italic text-[#6F6F6F]">` for italic emphasis; the rest is `#000000`.
327- **Classes:** `animate-fade-rise max-w-7xl font-display text-5xl font-normal text-[#000000] sm:text-7xl md:text-8xl`.
328- **Inline style:** `lineHeight: 0.95`, `letterSpacing: "-2.46px"`.
329- **Font:** Instrument Serif (`font-display`).
330- **Animation:** `animate-fade-rise`.
331
332### Description
333
334- **Text:** `Building platforms for brilliant minds, fearless makers, and thoughtful souls. Through the noise, we craft digital havens for deep work and pure flows.`
335- **Classes:** `animate-fade-rise-delay mt-8 max-w-2xl text-base leading-relaxed text-[#6F6F6F] sm:text-lg`.
336- **Color:** `#6F6F6F`.
337- **Animation:** `animate-fade-rise-delay`.
338
339### Hero CTA button
340
341- **Text:** `Begin Journey`.
342- **Classes:** `animate-fade-rise-delay-2 mt-12 rounded-full bg-[#000000] px-14 py-5 text-base text-[#FFFFFF] transition-transform duration-300 ease-out hover:scale-103`.
343- **Colors:** black background (`#000000`), white text (`#FFFFFF`).
344- **Hover:** scale `1.03` (`hover:scale-103`).
345- **Animation:** `animate-fade-rise-delay-2`.
346
347```tsx
348export default function Hero() {
349 return (
350 <section
351 className="relative z-10 flex flex-col items-center justify-center px-6 pb-40 text-center"
352 style={{ paddingTop: "calc(8rem - 75px)" }}
353 >
354 <h1
355 className="animate-fade-rise max-w-7xl font-display text-5xl font-normal text-[#000000] sm:text-7xl md:text-8xl"
356 style={{ lineHeight: 0.95, letterSpacing: "-2.46px" }}
357 >
358 Beyond <em className="italic text-[#6F6F6F]">silence,</em> we build{" "}
359 <em className="italic text-[#6F6F6F]">the eternal.</em>
360 </h1>
361
362 <p className="animate-fade-rise-delay mt-8 max-w-2xl text-base leading-relaxed text-[#6F6F6F] sm:text-lg">
363 Building platforms for brilliant minds, fearless makers, and thoughtful
364 souls. Through the noise, we craft digital havens for deep work and
365 pure flows.
366 </p>
367
368 <button
369 type="button"
370 className="animate-fade-rise-delay-2 mt-12 rounded-full bg-[#000000] px-14 py-5 text-base text-[#FFFFFF] transition-transform duration-300 ease-out hover:scale-103"
371 >
372 Begin Journey
373 </button>
374 </section>
375 );
376}
377```
378
379## Animations (`src/styles/theme.css`)
380
381The entrance choreography uses a single `fade-rise` keyframe with three staggered helper classes:
382
383- **`fade-rise`:** opacity `0 → 1`, `translateY(20px) → translateY(0)`, duration `0.8s`, `ease-out`.
384- **`.animate-fade-rise`:** `fade-rise 0.8s ease-out both`.
385- **`.animate-fade-rise-delay`:** same as fade-rise but with a `0.2s` delay.
386- **`.animate-fade-rise-delay-2`:** same as fade-rise but with a `0.4s` delay.
387- **Reduced motion:** under `@media (prefers-reduced-motion: reduce)`, all three classes settle instantly (`animation-duration: 0.01ms; animation-delay: 0ms;`) — no rise.
388
389```css
390/* ---- Entrance choreography ------------------------------------- */
391
392@keyframes fade-rise {
393 from {
394 opacity: 0;
395 transform: translateY(20px);
396 }
397 to {
398 opacity: 1;
399 transform: translateY(0);
400 }
401}
402
403.animate-fade-rise {
404 animation: fade-rise 0.8s ease-out both;
405}
406
407.animate-fade-rise-delay {
408 animation: fade-rise 0.8s ease-out 0.2s both;
409}
410
411.animate-fade-rise-delay-2 {
412 animation: fade-rise 0.8s ease-out 0.4s both;
413}
414
415/* Respect reduced-motion preferences: settle instantly, no rise. */
416@media (prefers-reduced-motion: reduce) {
417 .animate-fade-rise,
418 .animate-fade-rise-delay,
419 .animate-fade-rise-delay-2 {
420 animation-duration: 0.01ms;
421 animation-delay: 0ms;
422 }
423}
424```
425
426## File Structure
427
428```
429aethera-cinematic-hero/
430├── index.html
431├── package.json
432├── tsconfig.json
433├── tsconfig.app.json
434├── tsconfig.node.json
435├── vite.config.ts
436├── public/
437│ └── assets/
438│ └── hf_20260328_083109_283f3553-e28f-428b-a723-d639c617eb2b.mp4
439└── src/
440 ├── main.tsx
441 ├── App.tsx
442 ├── index.css
443 ├── vite-env.d.ts
444 ├── components/
445 │ ├── VideoBackground.tsx
446 │ ├── Navbar.tsx
447 │ └── Hero.tsx
448 └── styles/
449 ├── fonts.css
450 └── theme.css
451```
452
453## Scripts (`package.json`)
454
455- `dev`: `vite`
456- `build`: `tsc -b && vite build`
457- `preview`: `vite preview`
458