Docs
Next

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

ComponentRenders
MediaWrappera <div>, or the element in as; the play affordance when it wraps something playable
MediaWrapper.aa link through the LinkHelper; clicks navigate, never play
MediaOverlaya <div> layered over the media, faded out once the wrapper plays
MediaOverlayIcona <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:

  1. sets data-playing="true", which fades the MediaOverlay out and lets clicks through to the media,
  2. calls onPlayChange(true),
  3. 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.

Poster, then embed
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.

PropTypeDefaultDescription
classNamestring-
disableAnimationboolean-
aspectRatioAspectRatio (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)-
parallaxboolean-
parallaxNoWrapperboolean-
isPlayingboolean-
onPlayChange((isPlaying: boolean) => void)-
playVideoLabelstring-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.
playAffordanceboolean-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-
asElementType'div'
…and all <div> attributes

MediaOverlay and MediaOverlayIconLink to this section

PropTypeDefaultDescription
classNamestring-
childrenReactNode-
PropTypeDefaultDescription
classNamestring-
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.

ClassStyles
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.a renders through the Next-aware LinkHelper, 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 Video with deferred controls deferControlsLabel={null}, so the action has one name, the wrapper's.
  • Localize playVideoLabel in React; in Next.js it comes from the message catalog.
  • A MediaWrapper.a with only an image inside needs a name: an aria-label, or alt text on the image.