Skip to content

Video facade

A lightweight video poster that loads the player only when someone presses play.

Status
Beta
Category
Content
Adoption
Not used yet
import { VideoFacade } from "@oration/canon/components/video-facade";
packages/canon/src/components/video-facade.tsx

Build your first voice agent

Worth a watch before you test Where is my payment.

import { VideoFacade } from "@oration/canon/components/video-facade";import * as React from "react";export function Hero() {    const headingId = React.useId();    return (        <section            aria-labelledby={headingId}            className="w-full max-w-sm rounded-xl bg-card p-2 shadow-border"        >            <VideoFacade                video={{                    youtubeId: "aircAruvnKk",                    title: "Build your first voice agent",                    duration: "3:12",                }}                className="rounded-lg"            />            <div className="px-2 pt-2.5 pb-1.5">                <h2                    id={headingId}                    className="text-13 font-medium text-foreground"                >                    Build your first voice agent                </h2>                <p className="mt-0.5 text-xs text-muted-foreground">                    Worth a watch before you test Where is my payment.                </p>            </div>        </section>    );}

Usage#

Video facade is a help video's poster: the YouTube thumbnail, a play disc and the duration, which swaps to the privacy-enhanced player only when someone presses play. It keeps education cheap, so Get started, Info tips and Page intros can offer a video without loading a player on every page. The videos are stand-ins until Oration's own exist. The mistake is a vague title: the title is the button's accessible name and the iframe's, so it must say what the video teaches.

When to use

  • For a short help video beside the thing it explains: Build your first voice agent on Get started.
  • Inside an Info tip, for jargon that's faster to show than tell, such as Warm transfer.
  • In a dialog opened from Watch the 3-minute overview, with autoLoad since the click already happened.
  • For a short list of help videos on an overview or help page.

When not to use

  • For a page-level introduction with a video link. The banner handles dismissal and the dialog. Use Page intro
  • For a quick explanation of a term. Start with text; add the video inside it. Use Info tip
  • For call recordings or voicemail, which need a waveform and scrubbing. Use Waveform
  • For images or illustrations that don't play. Use Illustration

No autoplay without a click

The player loads and plays only after someone presses play, or with autoLoad inside a dialog they opened to watch it. Never autoload on page load.

The Tabular Figures Rule

The duration badge is set in tabular figures, and the same duration is spoken in words for screen readers: 3 minutes 12 seconds.

Anatomy#

3:12
  1. Frame. A 16:9 box with 12px corners on Well Gray, with a 10% ink outline (white at 10% in dark) so light thumbnails keep an edge.
  2. Thumbnail. The video's hqdefault poster from YouTube, lazy-loaded, covering the frame.
  3. Play disc. A 44px White Plane circle with a filled 18px play glyph and a soft shadow.
  4. Duration. A 20px badge at the bottom right: white 12px tabular figures on black at 75%.

Examples#

Default

The facade fills its container at 16:9. Press play to swap the poster for the player; nothing from YouTube's player loads before that.

import { VideoFacade } from "@oration/canon/components/video-facade";export function Default() {    return (        <VideoFacade            video={{                youtubeId: "wjZofJX0v4M",                title: "Test an agent before you publish",                duration: "4:05",            }}            className="max-w-md"        />    );}

In an info tip

Pass video to Info tip and it renders a facade under the explanation, for jargon such as warm transfer.

Warm transfer

import { InfoTip } from "@oration/canon/components/info-tip";export function InInfoTip() {    return (        <div className="flex items-center gap-1.5">            <h3 className="text-sm font-semibold text-foreground">                Warm transfer            </h3>            <InfoTip                title="Warm transfer"                description="The agent stays on the line and briefs a person in Contact Center before handing the supplier over."                video={{                    youtubeId: "zjkBMFhNj_g",                    title: "Route transferred calls in Contact Center",                    duration: "2:48",                }}                side="bottom"            />        </div>    );}

In a dialog

A button labelled with durationMinutes opens a dialog whose facade uses autoLoad, because the click already asked for the video.

import { Button } from "@oration/canon/components/button";import {  Dialog,  DialogContent,  DialogDescription,  DialogHeader,  DialogTitle,  DialogTrigger,} from "@oration/canon/components/dialog";import { durationMinutes, VideoFacade, type VideoRef } from "@oration/canon/components/video-facade";import { CirclePlayIcon } from "lucide-react";export function InDialog() {    const video: VideoRef = {        youtubeId: "kCc8FmEb1nY",        title: "Set up the web-call widget",        duration: "5:30",    };    return (        <Dialog>            <DialogTrigger                render={<Button type="button" variant="outline" size="sm" />}            >                <CirclePlayIcon data-icon="inline-start" aria-hidden="true" />                {`Watch the ${durationMinutes(video.duration)}-minute overview`}            </DialogTrigger>            <DialogContent className="sm:max-w-2xl">                <DialogHeader>                    <DialogTitle>{video.title}</DialogTitle>                    <DialogDescription>{`${video.duration} video`}</DialogDescription>                </DialogHeader>                <VideoFacade video={video} autoLoad className="rounded-lg" />            </DialogContent>        </Dialog>    );}

A list of help videos

Two posters side by side, each with its title and one line of why, as on a help or overview page.

  • Build your first voice agent

    Prompt, voice and a first test call.

  • Set up the web-call widget

    Embed it in the supplier portal.

import { VideoFacade, type VideoRef } from "@oration/canon/components/video-facade";export function HelpList() {    const videos: (VideoRef & { note: string })[] = [        {            youtubeId: "aircAruvnKk",            title: "Build your first voice agent",            duration: "3:12",            note: "Prompt, voice and a first test call.",        },        {            youtubeId: "kCc8FmEb1nY",            title: "Set up the web-call widget",            duration: "5:30",            note: "Embed it in the supplier portal.",        },    ];    return (        <ul className="grid w-full gap-4 sm:grid-cols-2">            {videos.map((video) => (                <li key={video.youtubeId} className="flex flex-col gap-2">                    <VideoFacade video={video} />                    <div>                        <p className="text-13 font-medium text-foreground">                            {video.title}                        </p>                        <p className="text-xs text-muted-foreground">                            {video.note}                        </p>                    </div>                </li>            ))}        </ul>    );}

States#

Rest
3:12
Hover
3:12
Pressed
3:12
import { cn } from "@oration/canon/lib/utils";import { PlayIcon } from "lucide-react";export function States() {    const states = [        { label: "Rest", disc: "" },        { label: "Hover", disc: "scale-[1.04]" },        { label: "Pressed", disc: "scale-[0.96]" },    ];    return (        <div className="grid w-full gap-4 sm:grid-cols-3">            {states.map((state) => (                <div key={state.label} className="flex flex-col gap-2">                    <span className="text-xs text-muted-foreground">                        {state.label}                    </span>                    <div className="relative aspect-video w-full overflow-hidden rounded-xl bg-muted outline -outline-offset-1 outline-black/10 dark:outline-white/10">                        <span className="absolute inset-0 grid place-items-center">                            <span                                className={cn(                                    "grid size-11 place-items-center rounded-full bg-background text-foreground shadow-md",                                    state.disc,                                )}                            >                                <PlayIcon                                    aria-hidden="true"                                    className="size-4.5 translate-x-[1.5px] fill-current"                                />                            </span>                        </span>                        <span className="absolute right-2 bottom-2 inline-flex h-5 items-center rounded-md bg-black/75 px-1.5 text-xs font-medium text-white tabular-nums">                            3:12                        </span>                    </div>                </div>            ))}        </div>    );}
States
StateTreatment
PosterThumbnail, play disc and duration. The whole frame is one button.
HoverThe play disc grows to 1.04 over 150ms on ease-out. No change under reduced motion.
PressedThe disc scales to 0.96 while held.
Focus visibleThe global 2px Focus Indigo outline applies, offset 2px outside the button, but the frame's overflow-hidden clips it, so no ring shows today. See Known gaps.
PlayingThe button is replaced by a youtube-nocookie.com iframe that autoplays, and focus moves into it.
LoadingBefore the thumbnail arrives the frame shows Well Gray with the play disc and duration already in place.

Behavior#

  • Nothing from the player loads until the button is pressed. The thumbnail itself is an <img> from i.ytimg.com, loaded lazily.
  • Pressing play renders the iframe with autoplay=1&rel=0 and moves focus into it, so keyboard users land in the player they asked for.
  • autoLoad renders the iframe straight away and doesn't move focus. Use it only where a click already asked for the video, such as a video dialog.
  • The frame fills its container's width at 16:9. Constrain it with max-w-* or the parent, and round it to the container's radius with className (for example rounded-lg inside a card with 8px padding).
  • spokenDuration("3:12") returns 3 minutes 12 seconds for the button's name; durationMinutes("3:12") returns 3, at least 1, for labels like Watch the 3-minute overview.

Do and don't#

Test an agent before you publish

Do. Title the video with what it teaches and show that title beside the poster.
Don't. Use a generic title such as Video. The play button is announced as Play video: Video, and the poster says nothing on its own.

Content#

  • Titles are sentence case and start with what you'll be able to do: Build your first voice agent, Set up the web-call widget.
  • Durations are clock time: 3:12, 1:04:30. Keep help videos under about five minutes.
  • A caption under the poster says why to watch now, in one line: Worth a watch before you test Where is my payment.
  • Links that open a video say its length: Watch the 3-minute overview.

Accessibility#

  • The poster is one <button> named Play video: {title}, {spoken duration}. The thumbnail has empty alt text and the disc and badge are aria-hidden.
  • The iframe is titled with video.title, so screen readers announce the player by name.
  • Focus moves into the player after pressing play, but not with autoLoad.
  • The disc's scale is turned off under reduced motion. YouTube's own player controls captions and autoplay once it loads.
  • The global focus outline sits 2px outside the button and the frame's overflow-hidden clips it, so keyboard users get no visible focus on the poster until the component draws an inset ring.
Keyboard interactions
KeysAction
TabMoves to the poster button.
EnterSpaceLoads the player and starts the video.

Design tokens#

Design tokens
TokenUsed for
--mutedFrame fill before the thumbnail loads
--backgroundThe play disc
shadow-mdThe play disc's shadow
--radius-xl12px frame corners
--ringThe global focus outline

API reference#

VideoFacade

The poster that becomes a player. Takes only the props below.

Props of VideoFacade
PropTypeDefaultDescription
videoRequired{ youtubeId: string; title: string; duration: string }No defaultThe video. title names the button and the iframe; duration is clock time such as "3:12".
autoLoadbooleanfalseRender the player immediately. Only where a click already asked for the video.
classNamestringNo defaultMerged onto the frame, usually for radius or max width.

VideoRef

Type: { youtubeId: string; title: string; duration: string }, shared with Info tip and Page intro.

No props of its own.

spokenDuration

(duration: string) => string. "3:12" becomes "3 minutes 12 seconds"; returns the input when it isn't clock time.

No props of its own.

durationMinutes

(duration: string) => number. Whole minutes, at least 1: "3:12" is 3.

No props of its own.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

The poster button's focus outline is drawn 2px outside it by the global rule, and the frame's overflow-hidden clips it. Keyboard focus on the poster draws no visible ring.

The thumbnail loads from i.ytimg.com as soon as the poster scrolls into view, so a request reaches Google before anyone presses play. Only the player uses the privacy-enhanced domain.

The videos are public YouTube explainers standing in for Oration's own. Get started still titles its stand-in with the original video's topic, A primer on how agents learn at 18:40.

The frame's edge is a CSS outline at 10% ink rather than the hairline lift.