Product ShowcaseBLK
A 5:3 poster frame with a circular play button; clicking it swaps to the native <video>, which then carries its own controls.
On this page (9)
§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 viadata-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:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
playing | boolean | true, false | false | Video 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