Video
Videos with deferred controls, preload and parallax.
On this page
Video renders a <video> with the same media props as Image: aspect ratio, object fit and position, and parallax. It also keeps the cost of a video low before anyone watches it: it preloads only metadata, and it can hold the native controls back until the visitor interacts, so they never shift the layout.
import { Video } from '@systhemaui/next'
export default function Demo() {
return (
<div className="w-full max-w-3xl">
<Video
src="/demo-assets/loop.mp4"
poster="/demo-assets/loop-poster.webp"
aspectRatio="16/9"
autoPlay
/>
</div>
)
}ImportLink to this section
import { Video } from '@systhemaui/next'In a React app without Next.js, import it from @systhemaui/react. The only difference is the aspectRatio default: '16/9' in React, 'auto' in Next.js.
ExamplesLink to this section
Background loopLink to this section
autoPlay also turns on loop and muted unless you set them, because browsers only autoplay muted video. playsInline defaults to true, so iOS does not open the video full screen.
import { Video } from '@systhemaui/next'
export default function Demo() {
return (
<div className="w-full max-w-xl">
<Video src="/demo-assets/loop.mp4" aspectRatio="21/9" autoPlay />
</div>
)
}Native controlsLink to this section
controls shows the browser's control bar. Add a poster so the frame is not blank before the first frame decodes.
import { Video } from '@systhemaui/next'
export default function Demo() {
return (
<div className="w-full max-w-3xl">
<Video
src="/demo-assets/loop.mp4"
poster="/demo-assets/loop-poster.webp"
aspectRatio="16/9"
controls
/>
</div>
)
}Aspect ratio and fitLink to this section
The aspect ratio and objectFit work as on Image. With objectFit="contain" the whole frame stays visible inside a box of another ratio.
import { Paragraph, Video } from '@systhemaui/next'
export default function Demo() {
return (
<div className="grid w-full max-w-xl grid-cols-2 gap-6">
<div className="flex flex-col gap-2">
<Video src="/demo-assets/loop.mp4" aspectRatio="1/1" objectFit="cover" autoPlay />
<Paragraph type="small">cover</Paragraph>
</div>
<div className="flex flex-col gap-2">
<Video src="/demo-assets/loop.mp4" aspectRatio="1/1" objectFit="contain" autoPlay />
<Paragraph type="small">contain</Paragraph>
</div>
</div>
)
}ParallaxLink to this section
parallax works as on Image: the video is wrapped in a parallax-wrapper figure that carries the aspect ratio, and the ParallaxListener moves it as the page scrolls.
import { Video } from '@systhemaui/next'
export function ParallaxBand() {
return <Video src="/videos/band.mp4" aspectRatio="21/9" autoPlay parallax />
}Loading and deferred controlsLink to this section
Three props decide what a video costs before anyone watches it.
preloadLink to this section
preload defaults to 'metadata': the browser reads the duration and paints the first frame without fetching the stream. 'auto' downloads the whole file during page load, which for a large hero video can be tens of megabytes inside the LCP window. 'none' fetches nothing until playback.
deferControlsLink to this section
A <video controls> draws its control bar only once metadata has loaded, so the bar appears over the video after the page has settled: a layout shift nobody asked for (0.185 CLS on one measured production page, its entire CLS). Shifts within 500 ms of a discrete user input do not count toward CLS, so deferControls holds the bar back until the visitor interacts and then reveals it for free.
Click the video below, or focus it and press Enter: the native controls appear and it starts playing.
import { Video } from '@systhemaui/next'
export default function Demo() {
return (
<div className="w-full max-w-3xl">
<Video
src="/demo-assets/loop.mp4"
poster="/demo-assets/loop-poster.webp"
aspectRatio="16/9"
controls
deferControls
/>
</div>
)
}What counts as the first interaction depends on how the video plays:
- A click-to-play video activates on a click (which every pointer, touch and assistive-technology activation produces), on Enter or Space, and on its
playevent, so an outside trigger such as aMediaWrapperposter works too. While its controls are deferred, the video itself is the play button:role="button",tabIndex={0}and thedeferControlsLabelname. - An autoplaying video activates on the first completed discrete input anywhere on the page: a pointer release, a key press (the first Tab included), or the click an assistive technology sends. It is not a play button, so it gets no
role="button". The trigger is the pointer release rather than the press, because a press that turns into a scroll is not a discrete input for CLS.
import { Video } from '@systhemaui/next'
export default function Demo() {
return (
<div className="w-full max-w-3xl">
<Video
src="/demo-assets/loop.mp4"
poster="/demo-assets/loop-poster.webp"
aspectRatio="16/9"
autoPlay
controls
deferControls
/>
</div>
)
}For autoplaying video this keeps WCAG 2.2.2 (pause, stop, hide) satisfied: anyone who wants it paused has already used an input device, so the focusable native control bar is there when they reach for it.
deferControlsLabelLink to this section
deferControlsLabel is the accessible name of the click-to-play affordance, 'Play video' by default. Translate it with the page. Pass null when an ancestor already provides the affordance, such as a MediaWrapper, which is itself role="button", so the action is not labelled twice. Autoplaying videos ignore it.
The same logic is available on its own as useDeferredVideoControls, which returns { controls, activationProps } to spread onto your own <video>.
PropsLink to this section
Video takes every <video> attribute on top of the media props. disableAnimation drops the configured animationClasses.
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | |
disableAnimation | boolean | - | |
aspectRatio | AspectRatio (auto, 32/9, 21/9, 21/9-md, 14/3, 5/2, 3/1, 4/1, 2/1, 16/5, 8/3, 16/9, 16/10, 8/5, 5/4, 4/3, 3/2, 1.618/1, 1/1, 9/8, 2/3, 3/4, 4/5, 6/7, 9/16, 10/16, 1/2, 9/21, 1/1.618) | - | |
parallax | boolean | - | |
parallaxNoWrapper | boolean | - | |
objectFit | 'contain' | 'cover' | null | - | |
objectPosition | 'top-left' | 'top' | 'top-center' | 'top-right' | 'center-left' | 'left' | 'center-center' | 'center' | 'center-right' | 'right' | 'bottom-left' | 'bottom' | 'bottom-center' | 'bottom-right' | { x?, y? } | null | - | |
deferControls | boolean | - | Hold the native controls back until the visitor interacts with the video. Off by default; see useDeferredVideoControls for why it exists. |
deferControlsLabel | string | null | - | Accessible name for the play affordance the video carries while its controls are deferred. null opts out when an ancestor (e.g. <MediaWrapper>) already provides one. Ignored for autoplaying videos, which are not play affordances. |
…and all <video> attributes |
HTML and CSSLink to this section
Without React, combine media with an aspect-ratio and an object-fit class:
<video
class="media aspect-16/9 object-cover object-center"
src="/loop.mp4"
poster="/loop-poster.webp"
preload="metadata"
playsinline
controls
></video>
<video
class="media aspect-21/9 object-cover"
src="/loop.mp4"
autoplay
muted
loop
playsinline
preload="metadata"
></video>Deferred controls are JavaScript; in plain HTML, add controls directly.
| Class | Styles |
|---|---|
media | .hero-simple-media:is(.media) { max-width: unset; left: 50%; translate: -50% 0; width: var(--hero-simple-media-width, 100%); }.hero-background-media:is(.media) { z-index: 5; position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: center; border-radius: 0px; box-shadow: none; }.hero-background-media:is(.media) > * { width: 100%; height: 100%; border-radius: 0px; box-shadow: none; }+ 11 more rules |
media-wrapper | .hero-simple-media:is(.media-wrapper) { max-width: unset; left: 50%; translate: -50% 0; width: var(--hero-simple-media-width, 100%); }.hero-background-media:is(.media-wrapper) { z-index: 5; position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: center; border-radius: 0px; box-shadow: none; }.hero-background-media:is(.media-wrapper) > * { width: 100%; height: 100%; border-radius: 0px; box-shadow: none; }+ 12 more rules |
media-overlay | & { z-index: 1; position: absolute; inset: 0; opacity: 1; pointer-events: none; transition-property: opacity; transition-duration: 500ms; transition-timing-function: ease-out; color: var(--color-media-icon); }.media:has(video) .media-overlay { pointer-events: auto; }.media-wrapper:has(video) .media-overlay { pointer-events: auto; }[data-playing="true"] .media-overlay { opacity: 0; pointer-events: none; }+ 7 more rules |
media-overlay-icon | & { position: absolute; top: 50%; left: 50%; translate: -50% -50%; color: var(--color-media-icon); box-shadow: inset 0 0 0 100vmax var(--color-media-overlay), 0 0 0 100vmax var(--color-media-overlay); width: var(--media-icon-size); height: var(--media-icon-size); font-size: var(--media-icon-size); line-height: var(--media-icon-size); }+ 1 more rule |
Next.jsLink to this section
The @systhemaui/next Video matches the React one, including preload and the deferred controls. One difference: aspectRatio defaults to 'auto' instead of '16/9', because Next.js projects render hero and background videos at their intrinsic size.
AccessibilityLink to this section
- Deferred controls keep the video keyboard-operable before the native bar exists, as described in
deferControls. - An autoplaying video is muted and loops. Keep the native controls (deferred or not), so visitors can pause it.
- The Payload Video block has no caption track field yet. When you render your own video with speech, add a
<track kind="captions">child.
See Embeds and viewport and Reduced motion.