Media
Media Band
A full-width looping video or photo between two sections that drifts against the page as it scrolls past.
Layout and motion from the Kalme template — see it in context"use client";
import * as React from "react";
import { Parallax } from "@/components/ui/parallax";
import { cn } from "@/lib/utils";
/*
* Media Band — a full-width moving picture between two sections: a looping video (or a photo) edge to edge that
* drifts against the page as it scrolls past.
* Source: marcframe/kalme → components/sections/PageVideoSection.tsx (+ design-system ScrollVideo, LoopVideo); the
* photo version is Nitip's page image band (marcframe/nitip → PageImageSection.tsx), left out of v1 for want of a
* category.
*
* Layout kept from Framer: no container or gutters — the frame spans the page, 480px tall (570px from 768px, 660px
* from 1024px), under 20px of space (30px, 40px). The media is 125% of the frame's height, centred, covering it, and
* clipped by the frame.
* Motion kept: the video plays muted and loops; the media drifts with the scroll (parallax) by a quarter of the band's
* height — 165px on desktops, 142px on tablets, 120px on phones, about Framer's 150px (owner, 2026-10-02: keep the
* movement clearly visible). Framer's media is 120% tall; the extra 5% keeps the edges out of view over the longer drift.
* With reduced motion nothing moves and the video stays on its poster.
* Changed: Framer ties the drift to the whole page's scroll (−150px at the top of the page, 0 at the bottom); here it
* follows the band's own pass through the window, so the band works anywhere on a page of any length. The space below
* matches the space above, so the band stacks between any two blocks.
* The band is decorative: hidden from assistive tech unless the photo has alt text.
*/
export type MediaBandProps = {
/** A looping, muted video; wins over `image`. The poster shows until it plays, and instead of it with reduced motion. */
video?: { src: string; poster?: string };
/** A photo instead of a video; give it alt text when it says something the page doesn't. */
image?: { src: string; alt?: string };
/** How far the media drifts over one pass, as a percentage of the band's height (0 = still). */
speed?: number;
className?: string;
};
const defaults = {
/* Pexels (free to use): a woman walking past glass office towers — replace with your own clip. */
video: {
src: "https://videos.pexels.com/video-files/5716910/5716910-hd_1280_720_25fps.mp4",
poster: "https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?auto=format&fit=crop&w=1600&q=80",
},
};
export function MediaBand({ video, image, speed = 25, className }: MediaBandProps) {
const media = video ?? (image ? undefined : defaults.video);
const described = !media && Boolean(image?.alt);
return (
<section aria-hidden={described ? undefined : true} className={cn("py-5 md:py-7.5 lg:py-10", className)}>
<Parallax speed={speed} className="h-120 bg-muted md:h-142.5 lg:h-165">
{media ? (
<LoopVideo src={media.src} poster={media.poster} />
) : (
<img src={image!.src} alt={image!.alt ?? ""} className="object-cover" />
)}
</Parallax>
</section>
);
}
/* A muted video that plays and loops on its own, covering its frame; it stays on the poster with reduced motion. */
function LoopVideo({ src, poster }: { src: string; poster?: string }) {
const ref = React.useRef<HTMLVideoElement>(null);
React.useEffect(() => {
const video = ref.current;
if (!video) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
const apply = () => {
if (reduce.matches) video.pause();
else void video.play().catch(() => {});
};
apply();
reduce.addEventListener("change", apply);
return () => reduce.removeEventListener("change", apply);
}, []);
return <video ref={ref} src={src} poster={poster} muted loop playsInline preload="metadata" className="object-cover" />;
}Installation
$ npx shadcn@latest add https://framebrick.co/r/blocks/media-band.json1. Add the Framebrick UI components it uses (they bring Motion).
npx shadcn@latest add https://framebrick.co/r/parallax.json2. Add the block tokens to
app/globals.cssonce (theming).npx shadcn@latest add https://framebrick.co/r/blocks/tokens.json3. Copy the block into
components/media-band.tsx."use client"; import * as React from "react"; import { Parallax } from "@/components/ui/parallax"; import { cn } from "@/lib/utils"; /* * Media Band — a full-width moving picture between two sections: a looping video (or a photo) edge to edge that * drifts against the page as it scrolls past. * Source: marcframe/kalme → components/sections/PageVideoSection.tsx (+ design-system ScrollVideo, LoopVideo); the * photo version is Nitip's page image band (marcframe/nitip → PageImageSection.tsx), left out of v1 for want of a * category. * * Layout kept from Framer: no container or gutters — the frame spans the page, 480px tall (570px from 768px, 660px * from 1024px), under 20px of space (30px, 40px). The media is 125% of the frame's height, centred, covering it, and * clipped by the frame. * Motion kept: the video plays muted and loops; the media drifts with the scroll (parallax) by a quarter of the band's * height — 165px on desktops, 142px on tablets, 120px on phones, about Framer's 150px (owner, 2026-10-02: keep the * movement clearly visible). Framer's media is 120% tall; the extra 5% keeps the edges out of view over the longer drift. * With reduced motion nothing moves and the video stays on its poster. * Changed: Framer ties the drift to the whole page's scroll (−150px at the top of the page, 0 at the bottom); here it * follows the band's own pass through the window, so the band works anywhere on a page of any length. The space below * matches the space above, so the band stacks between any two blocks. * The band is decorative: hidden from assistive tech unless the photo has alt text. */ export type MediaBandProps = { /** A looping, muted video; wins over `image`. The poster shows until it plays, and instead of it with reduced motion. */ video?: { src: string; poster?: string }; /** A photo instead of a video; give it alt text when it says something the page doesn't. */ image?: { src: string; alt?: string }; /** How far the media drifts over one pass, as a percentage of the band's height (0 = still). */ speed?: number; className?: string; }; const defaults = { /* Pexels (free to use): a woman walking past glass office towers — replace with your own clip. */ video: { src: "https://videos.pexels.com/video-files/5716910/5716910-hd_1280_720_25fps.mp4", poster: "https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?auto=format&fit=crop&w=1600&q=80", }, }; export function MediaBand({ video, image, speed = 25, className }: MediaBandProps) { const media = video ?? (image ? undefined : defaults.video); const described = !media && Boolean(image?.alt); return ( <section aria-hidden={described ? undefined : true} className={cn("py-5 md:py-7.5 lg:py-10", className)}> <Parallax speed={speed} className="h-120 bg-muted md:h-142.5 lg:h-165"> {media ? ( <LoopVideo src={media.src} poster={media.poster} /> ) : ( <img src={image!.src} alt={image!.alt ?? ""} className="object-cover" /> )} </Parallax> </section> ); } /* A muted video that plays and loops on its own, covering its frame; it stays on the poster with reduced motion. */ function LoopVideo({ src, poster }: { src: string; poster?: string }) { const ref = React.useRef<HTMLVideoElement>(null); React.useEffect(() => { const video = ref.current; if (!video) return; const reduce = window.matchMedia("(prefers-reduced-motion: reduce)"); const apply = () => { if (reduce.matches) video.pause(); else void video.play().catch(() => {}); }; apply(); reduce.addEventListener("change", apply); return () => reduce.removeEventListener("change", apply); }, []); return <video ref={ref} src={src} poster={poster} muted loop playsInline preload="metadata" className="object-cover" />; }
Usage
import { MediaBand } from "@/components/media-band";
<MediaBand video={{ src: "/video/studio.mp4", poster: "/video/studio.jpg" }} />
// or a photo
<MediaBand image={{ src: "/images/office.jpg" }} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| video | { src, poster? } | a Pexels clip | A muted loop; wins over image. The poster shows until it plays, and instead of it with reduced motion — use a still from the clip. |
| image | { src, alt? } | — | A photo instead of a video. With alt text the band is announced; otherwise it is decorative. |
| speed | number | 25 | How far the media drifts over one pass, as a percentage of the band's height (25 ≈ Framer's 150px); 0 holds it still. |
| className | string | — | Classes for the <section>. |
- Edge to edge, no container: the band is 480px tall (570px from 768px, 660px from 1024px) with 20px (30px, 40px) above and below; the media is 125% of its height and drifts a quarter of it with the scroll (165px on desktops, about Framer's 150px).
- Framer ties the drift to the whole page's scroll; here it follows the band's own pass through the window, so the band works anywhere on a page of any length.
- With reduced motion nothing moves and the video stays on its poster. The demo clip streams from Pexels; host your own file for production.
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this block in your own project.
Create a React + Tailwind CSS block called MediaBand (Next.js, client component). - <section className="py-5 md:py-7.5 lg:py-10"> with no container: a full-width frame (overflow-hidden, bg-muted) 480px tall, 570px from 768px, 660px from 1024px. - Inside, a layer 125% of the frame's height (top and bottom -12.5%) holding a muted, looping, playsInline <video> with a poster (or an <img>), object-cover. On scroll, move the layer from +12.5% to -12.5% of the frame height as the frame passes through the window (translate3d, written straight to the element in a passive scroll listener). - Reduced motion: no movement, and the video is paused on its poster. The section is aria-hidden unless the photo has alt text. - Only shadcn variables and block tokens.











