Video
A ratio-locked video, built the same way as Image and sharing its placeholder: reserve
Playground
Change a prop and watch it. The code below follows along.
Live
Open full cardVideo — Ratio-locked, upload-ready, Image's sibling.
Install and import
Video is in @dovetail-ds/react. Install it with React, if you haven't:
npm install @dovetail-ds/react react react-dom
import { Video } from "@dovetail-ds/react";
Import @dovetail-ds/react/styles.css once at your app's root, then your theme after it. Setup covers the stylesheets, fonts and dark mode.
Guidelines
A ratio-locked video, built the same way as Image and sharing its placeholder: reserve the space, degrade to a labelled drop target when there is nothing to show yet, upgrade to the real thing the moment there is.
Rules
- Give it a
label. It becomesaria-labelon the video and the placeholder's text, the same jobaltdoes on Image, and there is no other way to name a video for someone who cannot see it. controlsdefaults to true. Turn it off only for a background loop the page is driving on its own, and pair that withautoPlayandloop.autoPlaymutes itself unless you setmutedexplicitly. No browser plays audible video without a person starting it, so an autoplay prop with sound is not a stricter choice, it is a broken one.
Letting someone upload one
Pass onFile and the placeholder becomes a drop target, identical to Image's: a drag or a picker hands you the browser's own File, and Video does nothing else with it.
function Clip() {
const [file, setFile] = React.useState(null);
const src = file ? URL.createObjectURL(file) : undefined;
return <Video src={src} label="Product walkthrough" placeholder="Walkthrough clip" onFile={setFile} />;
}
Revoke the object URL when it is no longer needed. Storing, transcoding or streaming the file is a template's own concern; Video only gets you to a File.
Tradeoffs
A poster image is worth setting whenever the video is not autoplaying: without one, the frame is the flat placeholder colour until playback starts, which reads as broken sooner than it reads as loading.
Props
The typed contract, from Video.d.ts.
import * as React from "react";
import { AspectRatioProps } from "./AspectRatio";
/** A ratio-locked video, Image's sibling. Renders the same upload-ready placeholder when \`src\` is absent. */
export interface VideoProps extends React.HTMLAttributes<HTMLElement> {
/** Video URL. Omit to render the placeholder frame. */
src?: string;
/** Poster frame, shown before playback starts. */
poster?: string;
/** Accessible name. Also shown in the placeholder frame in place of \`placeholder\`. */
label?: string;
/** @default "16:9" */
ratio?: AspectRatioProps["ratio"];
/** How the video fills its frame. @default "cover" */
fit?: "cover" | "contain" | "fill" | "none" | "scale-down";
/** @default "media" */
radius?: "none" | "media" | "container" | "pill";
/** @default true */
controls?: boolean;
/**
* Requires \`muted\`, which is defaulted to true whenever this is true and
* \`muted\` is not given explicitly: no browser honours autoplay on audible
* video, so an unmuted autoplay prop would silently do nothing.
* @default false
*/
autoPlay?: boolean;
/** @default false */
loop?: boolean;
muted?: boolean;
/** Text shown in the placeholder frame. Falls back to \`label\`. */
placeholder?: string;
/**
* Turns the placeholder into a drop target, exactly as Image's does.
* Called with the browser's own File; nothing is read, transcoded or sent
* anywhere. Omit this to keep the placeholder a plain frame.
*/
onFile?: (file: File) => void;
}
export declare function Video(props: VideoProps): React.JSX.Element;
Tokens it reads
Every custom property Video.jsx resolves, read from the source: 5 semantic. Component tier first, because that is the one to reach for.
- semantic Shared roles. Override one and everything using that role moves with it.
| Token | Tier | Declared as |
|---|---|---|
--dt-radius-container | semantic | var(--dt-radius-raw-16) |
--dt-radius-media | semantic | var(--dt-radius-raw-12) |
--dt-radius-pill | semantic | var(--dt-radius-raw-full) |
--dt-size-icon-lg | semantic | var(--dt-dim-6) |
--dt-surface-sunken | semantic | var(--dt-color-neutral-100) |
Source
React only, no dependencies, every value a token.
import React from "react";
import { AspectRatio } from "./AspectRatio.jsx";
import { UploadFrame } from "./Image.jsx";
const RADII = { none: "0", media: "var(--dt-radius-media)", container: "var(--dt-radius-container)", pill: "var(--dt-radius-pill)" };
const PLACEHOLDER_ICON = (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ width: "var(--dt-size-icon-lg)", height: "var(--dt-size-icon-lg)" }} aria-hidden="true">
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="m10 9 5 3-5 3Z" />
</svg>
);
export function Video({ src, poster, label, ratio = "16:9", fit = "cover", radius = "media", controls = true, autoPlay = false, loop = false, muted, placeholder, onFile, style, ...rest }) {
const frame = { background: "var(--dt-surface-sunken)", borderRadius: RADII[radius] || RADII.media, ...style };
if (!src) {
return (
<AspectRatio ratio={ratio} style={frame} {...rest}>
<UploadFrame icon={PLACEHOLDER_ICON} label={placeholder || label || "Video"} hint="Drop a video, or choose a file" accept="video/*" onFile={onFile} />
</AspectRatio>
);
}
/* Autoplay that is not muted is a policy no browser honours, so an unmuted
autoplay prop would silently stop working the moment someone shipped it.
Defaulting to muted when muted is not given makes the common case, a
background loop, work without a second prop to remember. */
const isMuted = muted == null ? autoPlay : muted;
return (
<AspectRatio ratio={ratio} style={frame} {...rest}>
<video
src={src}
poster={poster}
controls={controls}
autoPlay={autoPlay}
loop={loop}
muted={isMuted}
playsInline
aria-label={label}
style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: fit, display: "block" }}
/>
</AspectRatio>
);
}
In context
Video also appears on the content reference cards, alongside the rest of its family.