Theme
On this page (9)
Component Skill — components/product-showcase/component-skill.md

Native basis

<figure> wrapper with three stacked layers - poster <img>, <video controls>, and a circular play <button>. Clicking the button (or pressing Enter on it - it's a real button) flips data-state to playing: CSS hides the poster and button, reveals the video, and JS starts muted playback. From then on the UA renders play/pause, scrubber, volume, and fullscreen. Native pause flips the state back so the poster returns.

Web Platform APIs

(`<video>`)(`<source type>`)preload="metadata"aspect-ratio(`popovertarget`-free first click)(`<track>`)Media pause event

Classes

.mk-showcase.mk-showcase-play

§Default

The play button is a real <button>; one click reveals <video controls>.

An embed - YouTube, Vimeo - takes the video's place: an <iframe> in the same layer, hidden and click-through until playing. A third-party player sets cookies, so load it through Cookie Consent - its live example plays a YouTube video only after you allow it - and keep the poster local.

<figure class="mk-showcase">
  <img class="mk-showcase-poster" src="poster.jpg" alt="Big Buck Bunny">
  <iframe title="Big Buck Bunny" allow="autoplay; encrypted-media; picture-in-picture" allowfullscreen
          data-cookie-consent="youtube" data-consent-placeholder="none"
          data-consent-src="https://www.youtube-nocookie.com/embed/aqz-KE-bpKQ"></iframe>
  <button type="button" class="mk-showcase-play" aria-label="Play video">…</button>
</figure>

§Browser frame

data-variant='browser': a window bar with three dots on top, the poster and the video below it - same play button, same State API.

§Ratios

data-ratio='wide' (16:9) for a film, data-ratio='square' (1:1) for a social clip; the default frame is 5:3.

§Callouts

.mk-showcase-stage gives the frame room; .mk-showcase-callout cards float over its corners (data-pos) - a blurred popover surface with an icon tile.

§Product tour

.mk-showcase-tour: Tabs above browser frames - each tab panel shows one screen of the product (posters only, no video).

§Tilt on scroll

.mk-showcase-stage[data-tilt]: the frame leans back and straightens as it scrolls into view - a scroll-driven animation (view timeline), no script. Scroll this example; reduced motion keeps it flat.

§CSS view file

/* -- Product Showcase (marketing) ----------------------------------
   5:3 poster frame with a circular play button. One click (JS) sets
   data-state="playing": the poster and button hide, the native <video>
   appears with the browser's own controls. Layers stack; state flips
   visibility via CSS so the JS only ever changes an attribute. */
@layer components {
  .mk-showcase {
    container: mk-showcase / inline-size;
    position: relative;
    aspect-ratio: 5 / 3;
    width: 100%;
    max-width: 80rem;
    margin-inline: auto;
    overflow: hidden;
    border-radius: var(--radius-lg);
    background: var(--muted);
    /* poster, video (or an embed's <iframe> - YouTube, Vimeo) and play
       button share the same cover layer */
    & .mk-showcase-poster,
    & :is(video, iframe),
    & .mk-showcase-play {
      position: absolute;
      inset: 0;
    }
    & .mk-showcase-poster,
    & :is(video, iframe) {
      width: 100%;
      height: 100%;
      object-fit: cover;
      display: block;
      background: var(--muted);
    }
    & iframe { border: 0; }
    /* state layering: poster+button until 'playing', video after; a waiting
       embed takes no clicks - only the play button acts */
    & :is(video, iframe) { opacity: 0; }
    & iframe { pointer-events: none; }
    &[data-state='playing'] {
      & .mk-showcase-poster { opacity: 0; }
      & .mk-showcase-play { opacity: 0; visibility: hidden; }
      & :is(video, iframe) { opacity: 1; pointer-events: auto; }
    }
  }
  /* centered circular play button - real <button>, keyboard-focusable;
     inset auto + margin auto centers the 64px box in the full-inset cell */
  .mk-showcase-play {
    z-index: 10;
    inset: auto;
    margin: auto;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 4rem;
    height: 4rem;
    padding: 0.75rem;
    border: none;
    border-radius: 9999px;
    background: var(--background);
    box-shadow: var(--shadow-2xs);
    cursor: pointer;
    transition: opacity 200ms ease, scale 300ms ease;
    /* CSS wins over the `fill="none"` presentation attribute, so a lucide
       stroke icon renders as a solid glyph inside the circle */
    & svg { width: 1.5rem; height: 1.5rem; fill: currentColor; }
    &:hover { scale: 1.08; }
    &:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
  }
  /* -- variants ------------------------------------------------- */
  /* browser: a window bar with three dots on top; the layers sit below it */
  .mk-showcase[data-variant="browser"] {
    border: 1px solid var(--border);
    background: var(--card);
    box-shadow: var(--shadow-xl);
    &::before {
      content: "";
      position: absolute;
      inset: 0 0 auto;
      z-index: 11;
      height: 2.25rem;
      border-block-end: 1px solid var(--border);
      background:
        radial-gradient(circle at 1rem 50%, oklch(0.7 0.18 25) 0.3125rem, transparent 0.34rem),
        radial-gradient(circle at 1.75rem 50%, oklch(0.8 0.15 85) 0.3125rem, transparent 0.34rem),
        radial-gradient(circle at 2.5rem 50%, oklch(0.75 0.16 150) 0.3125rem, transparent 0.34rem),
        var(--muted);
    }
    & :is(.mk-showcase-poster, video, iframe) { inset: 2.25rem 0 0; height: calc(100% - 2.25rem); }
    & .mk-showcase-play { inset: 2.25rem 0 0; }
  }
  /* ratios: 16:9 for video, 1:1 for social / mobile */
  .mk-showcase[data-ratio="wide"] { aspect-ratio: 16 / 9; }
  .mk-showcase[data-ratio="square"] { aspect-ratio: 1; }
  /* a stage around the showcase: room for callouts, perspective for the tilt */
  .mk-showcase-stage {
    position: relative;
    width: 100%;
    max-width: 80rem;
    margin-inline: auto;
    padding: 1.5rem;
    perspective: 1400px;
  }
  /* callouts: small cards floating over the frame's corners */
  .mk-showcase-callout {
    position: absolute;
    z-index: 12;
    display: flex;
    align-items: center;
    gap: 0.625rem;
    max-width: 16rem;
    padding: 0.625rem 0.875rem;
    border: 1px solid var(--border);
    border-radius: var(--radius-lg);
    background: color-mix(in oklch, var(--popover) 88%, transparent);
    color: var(--popover-foreground);
    box-shadow: var(--shadow-lg);
    backdrop-filter: blur(8px);
    font-size: 0.8125rem;
    & > svg { flex: none; padding: 0.375rem; width: 2rem; height: 2rem; border-radius: var(--radius-md); background: color-mix(in oklch, var(--primary) 12%, transparent); color: var(--primary); box-sizing: border-box; }
    & div { display: grid; }
    & span { color: var(--muted-foreground); }
    &[data-pos="top-start"] { top: 0; left: 0; }
    &[data-pos="top-end"] { top: 0; right: 0; }
    &[data-pos="bottom-start"] { bottom: 0; left: 0; }
    &[data-pos="bottom-end"] { bottom: 0; right: 0; }
  }
  /* tilt: leans back, straightens as it scrolls into view */
  @keyframes mk-showcase-untilt {
    from { rotate: x 22deg; scale: 0.9; opacity: 0.6; }
    to { rotate: x 0deg; scale: 1; opacity: 1; }
  }
  .mk-showcase-stage[data-tilt] .mk-showcase { transform-origin: 50% 100%; animation: mk-showcase-untilt linear both; }
  @supports (animation-timeline: view()) {
    .mk-showcase-stage[data-tilt] .mk-showcase { animation-timeline: view(); animation-range: entry 0% cover 45%; }
  }
  @supports not (animation-timeline: view()) {
    .mk-showcase-stage[data-tilt] .mk-showcase { animation: none; }
  }
  /* tour: Tabs above the frames - each panel shows one screen */
  .mk-showcase-tour {
    display: grid;
    gap: 1.25rem;
    width: 100%;
    max-width: 80rem;
    margin-inline: auto;
    justify-items: center;
    & .tab-list { flex-wrap: wrap; height: auto; }
    & .tab-content { width: 100%; }
  }
  /* -- Accessibility ------------------------------------------- */
  @media (prefers-reduced-motion: reduce) {
    .mk-showcase-stage[data-tilt] .mk-showcase { animation: none; }
  }
  @media (forced-colors: active) {
    .mk-showcase-callout { border-color: CanvasText; }
    .mk-showcase[data-variant="browser"]::before { border-block-end-color: CanvasText; }
  }
  @media (prefers-reduced-motion: reduce) {
    .mk-showcase-play { transition: none; }
  }
  @media (forced-colors: active) {
    .mk-showcase-play { outline: 1px solid ButtonText; }
  }
}

§States

Named states via the shared State API, driven per instance through the bound api:

  • default - poster + play button (the authored state)
  • playing - the poster and button hide (CSS via data-state), the video plays muted; pausing returns to the poster

The demo element carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/product-showcase-{state}.png.

Machine contract - verified against product-showcase.schema.json by bun run verify:

StateTypeValuesDefaultDescription
playingbooleantrue, falsefalseVideo playback (muted when driven headlessly); default pauses and rewinds.

§JavaScript view file

// -- Product Showcase ----------------------------------------
// A poster frame with a circular play button; clicking it swaps to the
// native <video> (which then carries its own controls). No custom player
// chrome - the browser draws everything once the video is visible.
// State API per AGENTS.md: 'default' (poster) | 'playing' (video).
// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —
// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.
import { defussGlobals } from '../../shared/state-api.js';
const df$ = defussGlobals();
const productShowcaseStates = ['default', 'playing'];
/**
 * UI side of setState: the only function touching the DOM for a state
 * change. 'playing' hides the poster/play button (CSS, via data-state) and
 * starts playback; 'default' pauses and rewinds so the poster returns.
 */
function triggerStateChange(showcase, stateName, _config) {
  const video = showcase.querySelector('video');
  switch (stateName) {
    case 'default':
      if (video) {
        video.pause();
        video.currentTime = 0;
      }
      showcase.dataset.state = 'default';
      break;
    case 'playing':
      showcase.dataset.state = 'playing';
      // muted: playback must never be blocked (or blast audio) when a
      // screenshot/agent drives setState without a user gesture
      if (video) {
        video.muted = true;
        video.play().catch(() => { /* autoplay blocked - poster stays, controls still work */ });
      }
      break;
  }
}
/** Registry-level API; pass the showcase element explicitly. Unknown names throw. */
export const productShowcaseApi = {
  setState(showcase, stateName, config = {}) {
    if (!productShowcaseStates.includes(stateName)) {
      throw new Error(
        `product-showcase: unknown state "${stateName}" (supported: ${productShowcaseStates.join(', ')})`,
      );
    }
    triggerStateChange(showcase, stateName, config);
    // state lives on the ELEMENT, not the module (multiple showcases per page)
    showcase.dataset.stateName = stateName;
    showcase._stateConfig = config;
  },
  getState(showcase) {
    // reflect reality: a user pausing the native controls returns to the poster
    const playing = showcase.dataset.state === 'playing';
    return {
      name: showcase.dataset.stateName || (playing ? 'playing' : 'default'),
      config: showcase._stateConfig ?? {},
    };
  },
};
df$.productShowcaseApi = productShowcaseApi;
df$.productShowcaseStates = productShowcaseStates;
function init() {
  document.querySelectorAll('.mk-showcase:not([data-init])').forEach((showcase) => {
    showcase.dataset.init = '';
    showcase.dataset.state = 'default';
    // bind-scope the api per instance: `$('#showcase').api.setState('playing')`
    showcase.api = {
      setState: (stateName, config) => productShowcaseApi.setState(showcase, stateName, config),
      getState: () => productShowcaseApi.getState(showcase),
    };
    showcase.querySelector('.mk-showcase-play')?.addEventListener('click', () => {
      productShowcaseApi.setState(showcase, 'playing');
    });
    // native pause/ended returns to the poster: route it through the API so
    // the visible state and getState() never diverge (pause covers `ended` too)
    showcase.querySelector('video')?.addEventListener('pause', () => {
      if (showcase.dataset.state === 'playing') productShowcaseApi.setState(showcase, 'default');
    });
  });
}
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

Comments, ideas or improvements? Edit this page's source on GitHub