MediaWrapper
Play affordance and overlays: MediaWrapper, MediaOverlay, MediaOverlayIcon.
On this page
MediaWrapper frames media in the same border, radius and aspect ratio as Image, and turns a poster into a play button. MediaOverlay puts a poster image over a video or an embed, and MediaOverlayIcon draws the play icon on it. Activating the wrapper hides the overlay and starts the video.
import { Image, MediaOverlay, MediaOverlayIcon, MediaWrapper, Video } from '@systhemaui/next'
export default function Demo() {
return (
<div className="w-full max-w-3xl">
<MediaWrapper aspectRatio="16/9" playAffordance>
<Video src="/demo-assets/loop.mp4" controls deferControls deferControlsLabel={null} />
<MediaOverlay>
<Image src="/demo-assets/loop-poster.webp" alt="" width={1280} height={720} />
<MediaOverlayIcon />
</MediaOverlay>
</MediaWrapper>
</div>
)
}ImportLink to this section
import { MediaOverlay, MediaOverlayIcon, MediaWrapper } from '@systhemaui/next'In a React app without Next.js, import them from @systhemaui/react.
Variants and tagsLink to this section
| Component | Renders |
|---|---|
MediaWrapper | a <div>, or the element in as; the play affordance when it wraps something playable |
MediaWrapper.a | a link through the LinkHelper; clicks navigate, never play |
MediaOverlay | a <div> layered over the media, faded out once the wrapper plays |
MediaOverlayIcon | a <span> centered on the overlay; empty, it draws the play icon |
The play affordanceLink to this section
The wrapper is a play button only when it wraps something playable. It inspects its direct children (and any fragments or arrays around them) and takes role="button", tabIndex={0} and an aria-label only when it finds a <Video>, a <MediaOverlay> poster or a plain <video>. That matches where core's CSS shows cursor: pointer. A still image, a map or another embed stays out of the tab order instead of announcing a "Play video" button that does nothing.
When activated (click, Enter or Space on the wrapper itself), the wrapper:
- sets
data-playing="true", which fades theMediaOverlayout and lets clicks through to the media, - calls
onPlayChange(true), - calls
play()on every<video>inside it.
Once playing, the wrapper drops the button role, so the native video controls take over.
Set playAffordance for a composition the detector cannot see through: a custom component that renders a <video> internally, or any wrapper you render in a Server Component. There the Video and MediaOverlay children reach the wrapper as client references, which the detector does not recognize, so the examples on this page that have no hooks set playAffordance explicitly. playAffordance={false} states that a wrapper is inert.
ExamplesLink to this section
Poster over an embedLink to this section
An embed you cannot script, such as a cross-origin <iframe>, can still sit behind a poster. Listen to onPlayChange and mount the embed when the visitor activates the wrapper, so its scripts load only on demand.
import { useState } from 'react'
import { Image, MediaOverlay, MediaOverlayIcon, MediaWrapper, Video } from '@systhemaui/next'
export default function Demo() {
const [playing, setPlaying] = useState(false)
return (
<div className="w-full max-w-3xl">
<MediaWrapper aspectRatio="16/9" onPlayChange={setPlaying} playVideoLabel="Play the tour">
{playing ? <Video src="/demo-assets/loop.mp4" autoPlay controls /> : null}
<MediaOverlay>
<Image src="/demo-assets/workspace.webp" alt="" width={1800} height={1200} />
<MediaOverlayIcon />
</MediaOverlay>
</MediaWrapper>
</div>
)
}In a real page, swap the Video for the embed's <iframe> with its autoplay parameter.
Linked mediaLink to this section
MediaWrapper.a makes the whole frame a link, for example to an article. It is never a play button. Inside an overlay, the poster zooms in slightly on hover.
import { Image, MediaOverlay, MediaWrapper } from '@systhemaui/next'
export default function Demo() {
return (
<div className="w-full max-w-md">
<MediaWrapper.a href="#linked-media" aspectRatio="4/3" aria-label="Read the coast story">
<MediaOverlay>
<Image src="/demo-assets/coast.webp" alt="" width={1800} height={1200} />
</MediaOverlay>
</MediaWrapper.a>
</div>
)
}Custom play iconLink to this section
Put your own icon in MediaOverlayIcon, or change the default for the whole project with blocks.media.overlayIcon (see Component CSS blocks). The icon is sized by --media-icon-size, and the overlay tint comes from --color-media-overlay.
import { Image, MediaOverlay, MediaOverlayIcon, MediaWrapper, Video } from '@systhemaui/next'
export default function Demo() {
return (
<div className="w-full max-w-3xl">
<MediaWrapper aspectRatio="16/9" playAffordance>
<Video src="/demo-assets/loop.mp4" controls deferControls deferControlsLabel={null} />
<MediaOverlay>
<Image src="/demo-assets/loop-poster.webp" alt="" width={1280} height={720} />
<MediaOverlayIcon>
<svg viewBox="0 -960 960 960" fill="currentColor" aria-hidden="true" focusable="false">
<path d="m380-300 280-180-280-180v360ZM480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Z" />
</svg>
</MediaOverlayIcon>
</MediaOverlay>
</MediaWrapper>
</div>
)
}PropsLink to this section
MediaWrapperLink to this section
playVideoLabel defaults to 'Play video' and names the affordance while the video is paused. A blank value falls back to the default, so the button is never nameless; unlike Video's deferControlsLabel it has no null opt-out, because the wrapper is the affordance. isPlaying makes the play state controlled. disableAnimation drops the configured animationClasses; the wrapper also switches off the scroll reveals of the media inside it, so the frame reveals as one piece.
| 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 | - | |
isPlaying | boolean | - | |
onPlayChange | ((isPlaying: boolean) => void) | - | |
playVideoLabel | string | - | Accessible name for the play affordance the wrapper itself becomes while its video is paused (role="button" + tabIndex={0}). Unlike <Video deferControlsLabel>, this has no null opt-out: the wrapper IS the affordance, so there is no ancestor to defer the labelling to, and dropping the name would leave a nameless role="button". A blank value therefore falls back to the English default rather than clearing it. |
playAffordance | boolean | - | Whether the wrapper renders as the play affordance (role="button", tabIndex={0} and an accessible name) for its media. Left undefined this is auto-detected: the wrapper is an affordance only when it actually wraps a <Video> or a <MediaOverlay> poster. Without that gate a still image or a bare embed became a focusable button that announced "Play video" and did nothing when activated. Set it explicitly for a composition the detector cannot see through — e.g. playAffordance on a wrapper whose custom child renders a <video> internally, or playAffordance={false} to state that a wrapper is inert. |
children (required) | ReactNode | - | |
as | ElementType | 'div' | |
…and all <div> attributes |
MediaOverlay and MediaOverlayIconLink to this section
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | |
children | ReactNode | - |
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | |
as | 'div' | 'span' | 'svg' | 'span' | |
…and all <span> attributes |
HTML and CSSLink to this section
The same structure with classes. Set data-playing="true" on the wrapper (from your own script) to fade the overlay out:
<div class="media-wrapper aspect-16/9" role="button" tabindex="0" aria-label="Play video">
<video class="media object-cover" src="/loop.mp4" preload="metadata" playsinline></video>
<div class="media-overlay">
<img src="/loop-poster.webp" alt="" />
<span class="media-overlay-icon"></span>
</div>
</div>An empty .media-overlay-icon draws the default play icon. While a wrapper with an overlay is not playing, it shows a pointer cursor.
| 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 twin behaves the same, with two differences:
MediaWrapper.arenders through the Next-awareLinkHelper, so the link prefetches and navigates on the client.- Without
playVideoLabel, the label comes from the Systhema frontend message catalog (accessibility.playVideo), so it follows the page locale when frontend locales are configured.
Children are matched by element identity first and then by display name, so a Video imported from @systhemaui/react inside the Next wrapper still counts as playable. The module that defines MediaWrapper is a server module that delegates to client components, so MediaWrapper.a works when imported in a Server Component.
AccessibilityLink to this section
- The wrapper is a button only while it wraps something playable and is not yet playing. It activates on Enter and Space as well as click, and ignores key presses that bubble up from controls inside it.
- Give a wrapped
Videowith deferred controlsdeferControlsLabel={null}, so the action has one name, the wrapper's. - Localize
playVideoLabelin React; in Next.js it comes from the message catalog. - A
MediaWrapper.awith only an image inside needs a name: anaria-label, oralttext on the image.