How to build a cinematic home page with a video background

By Mensur Duraković

Photo by GR Stocks on Unsplash

The page that talked me into this was ByteDance's launch.

You open it and there is no page, just video: full-bleed, edge to edge, a fisheye shot of a guy roaring on a beach volleyball court, with the nav floating on top of it and nothing else competing.

I closed the tab and went back to it twice, which is usually my signal that something is working on me.

My own blog home page, meanwhile, was a list of posts. Good list, honest list, completely forgettable. So I decided to steal the treatment, with one difference that turned out to matter for the rest of the build: their video is the product, so it can carry the whole frame alone.

Mine has to sit behind a headline, a subtitle, and a newsletter form, all of which have to stay readable while it moves.

The home page still had to keep the post index, the tag sections, search, newsletter signup, the sticky header, a reduced-motion fallback, and per-tenant branding, because the same Next.js app serves two domains.

So here is the TODO list I wish I had before I started, with the exact pieces I shipped on mensurdurakovic.com.

TODO 1: lock the visual rules first

Before you open a video model or write a line of CSS, decide the rules of the frame. Mine:

- canvas: #0b0c0e, near-black, with #131518 for raised surfaces

- text: #f4f6f7 off-white, dropping to #aeb4ba and #767d84 for secondary and muted

- accent: #c8ff00, acid lime, one color and no second one

- motion: slow abstract ink

- type: Space Grotesk for body, JetBrains for the kicker, display serif in the hero and nowhere else

- avoid: purple gradients, the blue-pink SaaS glow, generated UI text

These are the same custom properties the rest of the site already runs on, --color-canvas, --color-ink, --color-accent, which is the point.

The video has to look like it belongs to the interface.

Nothing in the palette moved, so the only addition was one font: Instrument Serif via next/fontbound to --font-hero and used once.

<h1 className="mt-6 max-w-4xl font-hero text-5xl leading-[1.02] tracking-[-0.01em] text-[color:var(--color-ink)] sm:text-6xl md:text-7xl lg:text-[5.5rem]">
  Code, people, and the craft{" "}
  <em className="text-[color:var(--color-accent)]">in between</em>.
</h1>

Checklist:

- One background color, one accent color.

- One display font, in one place.

- Real text lives in HTML, never inside the generated video.

- Everything else about your typography stays where it was.

TODO 2: generate the video loop

I used Google Flow with Omni Flash.

My first prompts were literal: a laptop on a desk, a code editor on screen. Both were wrong, and wrong in an instructive way.

Generative video is terrible at text. Names trip filters, code renders as confident gibberish, and any string you put in the prompt has a habit of showing up on screen in a font nobody chose.

Removing the objects fixed it. I switched to abstract ink because it reads as writing without illustrating it, it sits well on a black canvas, and it moves.

One detail cost me several generations. Fresh ink blooming into water is not a loop, it is an event. Once the ink spreads it cannot get back to frame one, so the seam is always visible. The prompt has to describe ink that is already suspended and drifting. Here is the version that worked:

Extreme macro of colored ink already suspended in still water, filling the frame. Deep black background. Slow, heavy, organic swirls of acid-lime, deep teal, and a thin thread of magenta drifting through each other, never fully mixing. Backlit so the ink glows at its edges while the surrounding water stays near-black. Fine particulate suspended in the liquid. No new ink is added, only slow continuous drift of what is already there. Very slow, almost imperceptible camera drift. Seamless 6-second loop with identical starting and ending frames, flawless looping, ultra-detailed 4K, cinematic realism, shallow depth of field, no text, no objects, no people.

Checklist:

- Abstract motion beats fake UI every time.

- Anything that loops needs "already suspended" or "already in motion" in the prompt.

- Keep negative constraints in their own paragraph at the end.

- Ban text, objects and people unless one of them is the actual subject.

- Generate a handful of candidates before you touch code. Picking is cheaper than fixing.

TODO 3: process the video asset

Export the clip, then produce two files:

- public/hero/loop.mp4

- public/hero/poster.jpg

The poster has to be a genuinely good frame, not whatever the exporter grabbed. It is what people see before the video paints, and it is the entire hero for anyone browsing with reduced motion.

My clip came out with a small sparkle watermark in the corner. Cropping would have wrecked the composition, so I used ffmpeg's delogo filter, which interpolates the surrounding pixels over a rectangle:

ffmpeg -i input.mp4 -vf "delogo=x=1720:y=36:w=142:h=70" -c:a copy output.mp4

Your x, y, w and h will be different. Open a frame, measure the watermark, run it on a short copy first.

Checklist:

- Export the MP4 loop and save a real poster frame.

- Remove watermarks without cropping when the composition matters.

- Watch the file size. A home page is not a video delivery benchmark.

- If the loop seam is visible, decide about rerolling now, not after you have built the hero around it.

TODO 4: build the hero component

In my app this is app/(public)/themes/default/hero.tsx:

import { SubscribeForm } from "./subscribe-form";
import { Reveal } from "../motion";

const VIDEO_SRC = "/hero/loop.mp4";
const POSTER_SRC = "/hero/poster.jpg";

const KICKER = "Engineering manager · frontend & mobile";
const SUBTITLE =
  "Field notes on frontend, mobile, and leading the people who ship it. New writing, straight to your inbox.";

export function Hero() {
  return (
    <section className="relative -mt-[72px] flex min-h-screen flex-col overflow-hidden bg-black">
      <img
        src={POSTER_SRC}
        alt=""
        aria-hidden
        className="absolute inset-0 h-full w-full object-cover"
      />
      <video
        className="hero-video absolute inset-0 h-full w-full object-cover"
        autoPlay
        muted
        loop
        playsInline
        poster={POSTER_SRC}
        aria-hidden
      >
        <source src={VIDEO_SRC} type="video/mp4" />
      </video>

      <div className="hero-scrim pointer-events-none absolute inset-0" aria-hidden />

      <div className="relative z-10 mx-auto flex w-full max-w-6xl flex-1 flex-col justify-center px-6 pt-28 pb-32">
        <Reveal>
          <p className="hero-legible flex items-center gap-3 font-mono text-xs uppercase tracking-[0.22em] text-[color:var(--color-ink)]">
            <span className="h-2 w-2 rounded-full bg-[color:var(--color-accent)]" />
            {KICKER}
          </p>
        </Reveal>

        <Reveal delay={0.06}>
          <h1 className="mt-6 max-w-4xl font-hero text-5xl leading-[1.02] tracking-[-0.01em] text-[color:var(--color-ink)] sm:text-6xl md:text-7xl lg:text-[5.5rem]">
            Code, people, and the craft{" "}
            <em className="text-[color:var(--color-accent)]">in between</em>.
          </h1>
        </Reveal>

        <Reveal delay={0.12}>
          <p className="hero-legible mt-7 max-w-xl font-sans text-base leading-relaxed text-[color:var(--color-ink)] md:text-lg">
            {SUBTITLE}
          </p>
        </Reveal>

        <Reveal delay={0.18}>
          <div className="mt-9">
            <SubscribeForm variant="hero" />
          </div>
        </Reveal>
      </div>

      <div className="hero-fade pointer-events-none absolute inset-x-0 bottom-0 z-10 h-48" aria-hidden />
    </section>
  );
}

Four attributes carry the whole thing: autoPlay, muted, loop and playsInline. Drop muted and browser autoplay policy blocks you. Drop playsInline and mobile Safari takes your background video fullscreen, which is a fun bug to discover on someone else's phone.

Checklist:

- Render the poster as a real <img> underneath the video, not only as the poster attribute.

- Mark image and video as decorative: alt="" plus aria-hidden.

- Content goes in a z-10 layer above both.

- Set the section background to black so there is no white flash on load.

- Keep the newsletter form in the hero only if it already belonged to the home page.

TODO 5: add reduced-motion support

The reduced-motion version is not a second design. It is the same frame, standing still:

@media (prefers-reduced-motion: reduce) {
  .hero-video {
    display: none;
  }
}

That is the payoff for rendering the poster as its own element. Hide the video and the hero still looks finished.

Checklist:

- Turn reduced motion on at the OS level and reload. Do not take the media query on faith.

- Confirm the poster is still visible.

- A slower autoplaying video is not an accessibility feature.

- Never strip the hero content along with the motion.

TODO 6: grade the video for readable text

A moving background is a moving contrast problem. My lime ink looked fantastic right up to the moment it drifted under the headline and took the contrast ratio with it.

A flat black overlay fixes contrast by making the video pointless. I wanted a scrim that goes dark where the copy sits and thins out where the video should still be visible. color-mix() makes that easy to tune:

.hero-scrim {
  background:
    linear-gradient(
      90deg,
      color-mix(in srgb, #000 64%, transparent) 0%,
      color-mix(in srgb, #000 30%, transparent) 42%,
      transparent 68%
    ),
    linear-gradient(
      180deg,
      color-mix(in srgb, #000 34%, transparent),
      transparent 24%,
      transparent 70%,
      color-mix(in srgb, #000 34%, transparent)
    );
}

Plus a small text shadow on the smaller copy, which does not need a scrim heavy enough to kill the video:

.hero-legible {
  text-shadow:
    0 1px 2px rgba(0, 0, 0, 0.55),
    0 2px 22px rgba(0, 0, 0, 0.45);
}

And a bottom fade so the video melts into the blog index instead of stopping at a hard edge:

.hero-fade {
  background: linear-gradient(
    to bottom,
    transparent 55%,
    color-mix(in srgb, var(--color-canvas) 72%, transparent) 82%,
    var(--color-canvas) 100%
  );
}

Checklist:

- Watch the whole loop, not the first frame. The bright part shows up somewhere in the middle.

- Check contrast at the loop's brightest moment under the headline.

- Darken the text side harder than the decorative side.

- Add the bottom fade whenever real content follows the hero.

- Retest on mobile, where the crop puts the bright areas somewhere else entirely.

TODO 7: make the header glass without making a second header

The header belongs to the site, not to the hero. I kept exactly one sticky header and taught it to work over both the video and the ordinary dark canvas:

<header className="sticky top-0 z-50 px-4 pt-4">
  <div className="liquid-glass mx-auto flex h-14 max-w-6xl items-center justify-between gap-4 rounded-full pr-3 pl-5">
    {/* logo, nav, search */}
  </div>
</header>

The glass is backdrop-filterplus a one-pixel gradient border, drawn with the mask-composite trick since you cannot put a gradient in border-color:

.liquid-glass {
  position: relative;
  background: color-mix(in srgb, var(--color-canvas) 58%, transparent);
  backdrop-filter: blur(14px) saturate(1.4);
  -webkit-backdrop-filter: blur(14px) saturate(1.4);
  overflow: hidden;
}

.liquid-glass::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  padding: 1px;
  background: linear-gradient(
    180deg,
    color-mix(in srgb, var(--color-ink) 26%, transparent),
    color-mix(in srgb, var(--color-ink) 6%, transparent) 42%,
    transparent 62%,
    color-mix(in srgb, var(--color-ink) 16%, transparent)
  );
  -webkit-mask:
    linear-gradient(#000 0 0) content-box,
    linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask-composite: exclude;
  pointer-events: none;
}

The hero slides underneath the header with a negative top margin:

<section className="relative -mt-[72px] flex min-h-screen flex-col overflow-hidden bg-black">

That number only works if the header height never changes. Mine did: the old mobile header dropped a second row of links below the pill, so on small screens the offset was wrong and the hero sat too high. I removed that behavior and kept the pill at one height at every breakpoint. A hardcoded offset is a promise you make to yourself about layout, and this one had been broken since before I wrote it.

Checklist:

- One header component. Not one for the hero and one for everything else.

- Constant header height across breakpoints.

- Pull the hero up by exactly the header space.

- Add top padding inside the hero so the headline clears the nav.

- Test every page, not only the home page. Interior pages have no video to hide behind.

TODO 8: wire the hero into the existing home page

Do not replace the page unless replacing the page was the goal. Mine still renders the hero, then the latest post, then the rest of the index:

export function Home({ data }: { data: HomeData }) {
  const { latest, sections, tagColumns } = data;
  const [hero, ...rest] = latest;

  return (
    <div>
      <Hero />

      {!hero ? (
        <p className="mx-auto max-w-2xl px-6 py-32 text-center text-[color:var(--color-ink-3)]">
          No posts yet.
        </p>
      ) : null}

      {/* latest post and the rest of the index continue here */}
    </div>
  );
}

Checklist:

- Existing data flow stays as it is.

- Empty states survive.

- Search and newsletter behavior survive.

- Tag sections and pagination survive.

- A content site does not become a splash page by accident.

TODO 9: add the logo, but make it tenant-aware

My site had no logo and no favicon, so I drew an MD monogram. The right leg of the M doubles as the spine of the D, which is what keeps it legible at 16 pixels:

type LogoProps = {
  className?: string;
  title?: string;
};

export function Logo({ className, title = "Mensur Duraković" }: LogoProps) {
  return (
    <svg
      viewBox="0 0 118 110"
      className={className}
      role="img"
      aria-label={title}
      fill="none"
      stroke="currentColor"
      strokeWidth={15}
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <path d="M15 86 V24 L44 62 L73 24 V86" />
      <path d="M73 26 A29 29 0 0 1 73 84" />
    </svg>
  );
}

Stroking with currentColor means the header can hand it the accent color and hover can scale it, without a second SVG:

<Logo className="h-6 w-auto text-[color:var(--color-accent)] transition-transform duration-300 group-hover:scale-105" />

For the favicon, go full-bleed square. Transparent rounded corners look correct in exactly one browser tab and wrong in every other place the icon lands, because the surface behind it keeps changing.

And if your app serves more than one domain, do not drop a global icon in public/favicon.ico unless every tenant should wear it. My app serves two sites, so the icon metadata is resolved per tenant. My personal site gets the MD mark. The other site does not inherit my initials, which is the kind of leak you only notice from a bookmark bar three weeks later.

Checklist:

- The mark has to work at header size and at favicon size.

- Use currentColor if it should follow the theme.

- Full-bleed favicon background.

- Check tabs, bookmarks and link previews.

- In a multi-tenant app, emit icons per tenant.

TODO 10: verify the boring stuff

This is the part that turns a demo into a page you can leave up:

- Load it on desktop and on a real phone.

- Watch the full loop and look for the seam.

- Turn on reduced motion, reload, confirm the poster carries the hero.

- Confirm the headline stays readable through every frame.

- Scroll past the hero and check the sticky header still behaves.

- Open search from the glass header.

- Submit the newsletter form from the hero.

- Load the home page with zero posts if your app has that state.

- Load every domain and confirm the right branding shows up.

- Run typecheck and lint if you touched code.

If you want to steal this

Do not start with the video.

Start with the frame it has to live in: your palette, the text that must stay readable on top of it, how tall your header is at every breakpoint, what the page looks like when someone turns motion off.

Generate the loop after that and it drops into a page you already understand. Generate it first and you will spend the afternoon bending the page around a clip you have already fallen in love with, which is a fight the clip usually wins.

That is the real difference between my page and the one that sent me down this road.

Seedance can put a video on screen and stop, because the video is the product.

My blog is not selling a video. It is for blog posts, and the hero is a doorway people walk through on the way to a list of them.