Showreel Hero
Give a film a proper introduction. A cinematic poster, considered typography, and a native video viewer visitors choose to open and play.

INDEPENDENT STORIES / FIELD NOTES 01
Stories worth staying for.
A moving image can hold a whole world. We make room for the stories that deserve a little more time.
FIELD STUDY / AN ORIGINAL SILENT MOTION PIECEOPEN. PLAY. TAKE A MOMENT.
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import './showreel-hero.css';
export interface ShowreelHeroProps {
eyebrow?: string;
title: string;
description?: string;
poster: string;
posterAlt?: string;
videoSrc: string;
videoLabel?: string;
captionsSrc?: string;
transcript?: string;
className?: string;
}
/** A cinematic poster and an explicitly opened, manually played native video. */
export function ShowreelHero({
eyebrow,
title,
description,
poster,
posterAlt = '',
videoSrc,
videoLabel = 'Watch the film',
captionsSrc,
transcript,
className = '',
}: ShowreelHeroProps) {
const [expanded, setExpanded] = useState(false);
const [failedSource, setFailedSource] = useState<string | null>(null);
const dialog = useRef<HTMLDialogElement>(null);
const video = useRef<HTMLVideoElement>(null);
const opener = useRef<HTMLButtonElement>(null);
const instance = useId();
const titleId = `ac-showreel-title-${instance}`;
const dialogId = `ac-showreel-dialog-${instance}`;
const dialogTitleId = `${dialogId}-title`;
useEffect(() => {
if (!expanded) return;
const modal = dialog.current;
const player = video.current;
if (!modal) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
if (!modal.open) modal.showModal();
return () => {
player?.pause();
try {
if (player) player.currentTime = 0;
} catch {
// A source with no loaded metadata may not have a seekable timeline yet.
}
if (modal.open) modal.close();
document.body.style.overflow = previousOverflow;
opener.current?.focus({ preventScroll: true });
};
}, [expanded]);
function openFilm() {
setExpanded(true);
}
return (
<section className={`ac-showreel-hero ${className}`.trim()} aria-labelledby={titleId}>
<img className="ac-showreel-poster" src={poster} alt={posterAlt} />
<div className="ac-showreel-shade" aria-hidden="true" />
<div className="ac-showreel-content">
<div className="ac-showreel-intro">
{eyebrow && <p className="ac-showreel-eyebrow">{eyebrow}</p>}
<h2 id={titleId}>{title}</h2>
</div>
<div className="ac-showreel-bottom">
{description && <p className="ac-showreel-description">{description}</p>}
<button
ref={opener}
type="button"
className="ac-showreel-play"
aria-haspopup="dialog"
aria-expanded={expanded}
aria-controls={dialogId}
onClick={openFilm}
>
<span className="ac-showreel-play-icon" aria-hidden="true">
<svg viewBox="0 0 20 20" width="20" height="20" fill="currentColor"><path d="M7 4.5v11L15.5 10z" /></svg>
</span>
<span>{videoLabel}</span>
<span className="ac-showreel-play-arrow" aria-hidden="true">↗</span>
</button>
</div>
</div>
<dialog
ref={dialog}
id={dialogId}
className="ac-showreel-dialog"
aria-labelledby={dialogTitleId}
onCancel={event => { event.preventDefault(); setExpanded(false); }}
onClose={() => { if (!dialog.current?.open) setExpanded(false); }}
>
<header className="ac-showreel-dialog-header">
<h2 id={dialogTitleId}>{videoLabel}</h2>
<button type="button" autoFocus onClick={() => setExpanded(false)}>Close <span aria-hidden="true">×</span></button>
</header>
<video
ref={video}
className="ac-showreel-video"
src={videoSrc}
poster={poster}
controls
playsInline
preload="none"
aria-label={videoLabel}
onError={() => setFailedSource(videoSrc)}
onLoadedMetadata={() => setFailedSource(null)}
>
{captionsSrc && <track kind="captions" src={captionsSrc} srcLang="en" label="English captions" default />}
Your browser cannot play this video. <a href={videoSrc}>Open the video file</a>.
</video>
{failedSource === videoSrc && <p className="ac-showreel-error" role="alert">This video could not be loaded. <a href={videoSrc} target="_blank" rel="noreferrer">Open the video file</a> or check the video source.</p>}
<div className="ac-showreel-dialog-copy">
<p className="ac-showreel-play-note">Use the player controls to start the film.</p>
{transcript && <details className="ac-showreel-transcript"><summary>Read the transcript</summary><p>{transcript}</p></details>}
</div>
</dialog>
</section>
);
}
Installation
Run this in your shadcn-configured React project. The component and stylesheet are added together.
npx shadcn@latest add http://localhost:5173/r/showreel-hero.jsonThis command uses the current site address. For localhost, keep this preview running on the same computer. Files follow your configured components alias.
Usage
Import the component and make the details your own.
import { ShowreelHero } from '@/components/anticraft/ShowreelHero';
export function Example() {
return (
<ShowreelHero
eyebrow="FRAME / INDEPENDENT FILM"
title={'Stories worth\nstaying for.'}
description="A moving image can hold a whole world. Make room for the stories that deserve a little more time."
poster="/images/film-poster.jpg"
posterAlt="A wide landscape under an open sky"
videoSrc="/videos/film.mp4"
videoLabel="Watch the film"
captionsSrc="/videos/film.en.vtt"
transcript="Add the full dialogue, meaningful sounds, and visual context for your film here."
/>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | Required | The visible section heading. A newline creates an intentional line break; long text can still wrap naturally. |
poster | string | Required | Your own poster image URL. It fills the hero with object-fit: cover and also appears in the native player before playback. |
videoSrc | string | Required | A browser-supported video file URL. The player uses preload="none", native controls, and playsInline. Opening the viewer does not start playback. |
eyebrow | string | undefined | Optional short context above the heading, such as a studio name or film collection. |
description | string | undefined | Optional visible introduction at the foot of the poster. |
posterAlt | string | '' | A meaningful description of the poster when it conveys information. Leave empty for a purely decorative poster whose meaning is already in the visible copy. |
videoLabel | string | 'Watch the film' | The visible opening button, dialog heading, and accessible player label. |
captionsSrc | string | undefined | Optional URL to your own valid English WebVTT captions file. The native captions track is labeled English captions and marked default. Supply accurate captions for speech and meaningful audio; adapt the source track language if your film uses another language. |
transcript | string | undefined | Optional complete readable transcript in a native disclosure beneath the player. Include dialogue, important sounds, and needed visual context. The silent original demo uses a short visual description. |
className | string | '' | Additional classes. Theme the poster foreground and opening button with --ac-showreel-paper and --ac-showreel-ink on the root. |
Motion & accessibility
The poster stays still. Watch opens a native modal; the visitor then presses Play in the video controls. Nothing autoplays or loops. The player includes native pause, seeking, volume, captions when supplied, and fullscreen controls. Escape or Close pauses the video, returns it to the beginning, restores background scrolling, and returns focus to the opening button. Cleanup also runs on unmount and is safe during React Strict Mode effect replay. Failed video loading displays a useful message and a link to the original file. A small opening-button hover lift is removed for reduced motion. The demo film is an original silent eight-second coastal visual study, with a slow camera move and no dialogue or sound. Provide your own poster, video, captions, and transcript for the example paths.
Use semantic content and meaningful labels in your composition. Check contrast and keyboard behavior with your own styles and content.