Theme
Design your own
On this page (7)
Component Skill — components/teaser/component-skill.md

Native basis

<section class="teaser"> with a .play-button, a title, a text and a <template class="teaser-content"> - inert content until df$(el).morph() puts it in the teaser's place.

Web Platform APIs

<template>aspect-ratio@starting-style:has()focus()prefers-reduced-motion

Classes

.teaser.teaser-title.teaser-text.teaser-media.teaser-content

Data attributes

data-ratio16/9 · 4/3 · 1/1 · 21/9 - the shape of what it stands fordata-playedSet by the runtime once the content is in place

Notes

• A click anywhere on the card plays it, except on another control inside.

• After a click, focus moves to the content's first control - or to the teaser itself.

• Scripts inside the content do not run; components inside it initialize as usual.

§Default

Click the card or its play button: the template's content - a card with a table - takes the teaser's place. Before that, nothing inside the template exists on the page.

§Attention: in an aura

The Shapes module's aura draws a turning light around the card and a pulsing play button asks to be pressed. Played, the whole slide deck loads - an iframe that did not exist before - and the aura stops.

§With a poster

A .teaser-media image behind the text, a glass play button: the video and its file load only once it is played - and then it starts.

§States

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

  • default - the teaser: the play button, the title and the text (setState('default') brings it back)
  • played - the template's content in the teaser's place, data-played on the element

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

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

StateTypeValuesDefaultDescription
playedbooleantrue, falsefalseThe template's content in the teaser's place (data-played).

§API

Generated from teaser.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.

States

type TeaserState = 'default' | 'played' - setState(name, config) takes the config of the state it names.

StateDescription
default
The teaser: the play button, the title and the text - the content is not loaded.

No config.

played
The content of the teaser's template, morphed in place of the teaser.

No config.

Every element

MemberDescription
el.api.setState<S extends TeaserState>(name: S, config?: TeaserStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
nameSa declared state (an unknown name throws)
config?TeaserStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (or await settled())

el.api.getState(): { name: TeaserState; config: TeaserStateConfigs[TeaserState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.

Returns { name: TeaserState; config: TeaserStateConfigs[TeaserState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

el.api.render(state?: { name: TeaserState; config: TeaserStateConfigs[TeaserState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state?{ name: TeaserState; config: TeaserStateConfigs[TeaserState]; model?: ElementModel }a state as getState() returns it (default: the current one)

Returns string - the element's outer HTML in that state

el.api.settled(): Promise<void>
Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting).

Returns Promise<void> - resolves when nothing is pending

el.store: Store<{ name: TeaserState; config: TeaserStateConfigs[TeaserState] }>A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component.

Registry

MemberDescription
df$.shadcn.teaserApi.setState<S extends TeaserState>(el: HTMLElement, name: S, config?: TeaserStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSa declared state (an unknown name throws)
config?TeaserStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (await it, or el.api.settled())

df$.shadcn.teaserApi.getState(el: HTMLElement): { name: TeaserState; config: TeaserStateConfigs[TeaserState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.
ArgumentTypeDescription
elHTMLElementthe component's element

Returns { name: TeaserState; config: TeaserStateConfigs[TeaserState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

df$.shadcn.teaserApi.render(state: { name: TeaserState; config: TeaserStateConfigs[TeaserState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state{ name: TeaserState; config: TeaserStateConfigs[TeaserState]; model?: ElementModel }a state as getState() returns it (with its model)

Returns string - the element's outer HTML in that state

df$.shadcn.teaserApi.store(el: HTMLElement): Store<{ name: TeaserState; config: TeaserStateConfigs[TeaserState] }>
The element's store (bindComponent made it).
ArgumentTypeDescription
elHTMLElementthe component's element

Returns Store<{ name: TeaserState; config: TeaserStateConfigs[TeaserState] }> - a defuss-store store of { name, config } - subscribe to follow every change, set it to drive the component

df$.shadcn.teaserApi.commit<S extends TeaserState>(el: HTMLElement, name: S, config?: TeaserStateConfigs[S]): void
Record a state the element reached on its own (no DOM work) - for a component's own handlers.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSthe state it is in
config?TeaserStateConfigs[S]its config
df$.shadcn.teaserStates: TeaserState[]The declared states, 'default' first: default, played.

§CSS view file

/* -- Teaser component -----------------------------------------
   A card that stands in for content that should not load yet - a deck, a
   video, a heavy widget. Its play button (or a click anywhere on the card)
   morphs the content of its <template> into place; until then nothing in
   the template loads (template content is inert). Around it, the Shapes
   module's aura draws the eye - and stops once the teaser has played. */
@layer components {
  .teaser {
    /* border-box: data-ratio is the whole card's shape, padding and border included */
    box-sizing: border-box;
    position: relative;
    display: grid;
    align-content: center;
    justify-items: center;
    gap: 0.75rem;
    min-inline-size: 0;
    padding: 2.5rem 1.5rem;
    overflow: hidden;
    border: 1px solid var(--border);
    border-radius: var(--radius-xl);
    background-color: var(--card);
    color: var(--card-foreground);
    text-align: center;
    cursor: pointer;
    /* the size of what it stands for: the swap does not move the page */
    &[data-ratio="16/9"] {
      aspect-ratio: 16 / 9;
    }
    &[data-ratio="4/3"] {
      aspect-ratio: 4 / 3;
    }
    &[data-ratio="1/1"] {
      aspect-ratio: 1 / 1;
    }
    &[data-ratio="21/9"] {
      aspect-ratio: 21 / 9;
    }
    & > .play-button {
      margin-block-end: 0.5rem;
    }
    &:hover:not([data-played]) > .play-button:not(:disabled) {
      scale: 1.06;
    }
    /* the card shows where the keyboard is */
    &:has(> .play-button:focus-visible) {
      box-shadow: 0 0 0 2px var(--ring);
    }
    /* -- Played: the content takes the whole card ------------------- */
    &[data-played] {
      display: block;
      /* the card's centring would shrink a block child to its content's width */
      align-content: normal;
      justify-items: normal;
      aspect-ratio: auto;
      padding: 0;
      overflow: visible;
      border: 0;
      background: none;
      color: inherit;
      text-align: start;
      cursor: auto;
      & > * {
        transition: opacity 400ms ease;
        @starting-style {
          opacity: 0;
        }
      }
    }
  }
  .teaser-title {
    margin: 0;
    font-size: 1.25rem;
    font-weight: 600;
    line-height: 1.3;
    text-wrap: balance;
  }
  .teaser-text {
    max-inline-size: 36rem;
    margin: 0;
    color: var(--muted-foreground);
    font-size: 0.9375rem;
    line-height: 1.5;
    text-wrap: pretty;
  }
  /* -- A poster behind the text: a photo or a video still ------------ */
  .teaser-media {
    position: absolute;
    inset: 0;
    z-index: 0;
    inline-size: 100%;
    block-size: 100%;
    object-fit: cover;
  }
  .teaser:has(> .teaser-media):not([data-played]) {
    color: oklch(1 0 0);
    /* a scrim keeps the text readable on any photo */
    &::after {
      content: "";
      position: absolute;
      inset: 0;
      z-index: 0;
      background: linear-gradient(to top, oklch(0 0 0 / 0.72), oklch(0 0 0 / 0.15) 70%);
    }
    & > :not(.teaser-media) {
      position: relative;
      z-index: 1;
    }
    & .teaser-text {
      color: oklch(1 0 0 / 0.85);
    }
  }
  /* -- Accessibility ------------------------------------------------- */
  @media (prefers-reduced-motion: reduce) {
    .teaser {
      &:hover:not([data-played]) > .play-button:not(:disabled) {
        scale: none;
      }
      &[data-played] > * {
        transition: none;
      }
    }
  }
  @media (prefers-contrast: more) {
    .teaser:not([data-played]) {
      border-color: var(--foreground);
    }
    .teaser-text {
      color: var(--foreground);
    }
  }
  @media (forced-colors: active) {
    .teaser:not([data-played]) {
      border-color: CanvasText;
    }
    .teaser:has(> .play-button:focus-visible) {
      outline: 2px solid Highlight;
    }
  }
}

§JavaScript view file

One markup function per state - played morphs the template's content in, default morphs the authored teaser back; render() runs it on a detached copy.

// -- Teaser ---------------------------------------------------
// A card that stands in for deferred content: its play button - or a click
// anywhere on the card - morphs the content of its <template class=
// "teaser-content"> into place through defuss-morph. Template content is
// inert, so nothing in it loads (no image, video, iframe, script) until then.
// States: default (the teaser) and played (the content); render(state)
// reproduces the markup of either (AGENTS.md "State API").
// 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,
  defussQuery,
  componentState,
  bindComponent,
  settleTemplates,
} from '../../../shared/state-api.js';
const df$ = defussGlobals();
const dfDollar = defussQuery();
const teaserStates = ['default', 'played'];
// VERIFIED: (verify's API docs gate) the states below are exactly the declared ones, each
// described, and every config field typed, described and named in the code.
/** setState() configs per state - the teaser's states take none. */
export interface TeaserStateConfigs {
  /** The teaser: the play button, the title and the text - the content is not loaded. */
  default: {};
  /** The content of the teaser's template, morphed in place of the teaser. */
  played: {};
}
/** The two markups a teaser switches between, read once from its authored state. */
interface TeaserParts {
  /** the authored inner markup - the teaser itself, its template included */
  teaser: string;
  /** the template's content - the markup that replaces the teaser when it plays */
  content: string;
}
/** both markups of a teaser in its authored (default) state */
function partsOf(el: HTMLElement): TeaserParts {
  const template = dfDollar(el).find('template.teaser-content');
  return { teaser: dfDollar(el).html() ?? '', content: template.length ? (template.html() ?? '') : '' };
}
/**
 * The markup of a state - the one place a state becomes markup. setState runs
 * it on the live teaser, render() on a detached copy of the authored markup.
 * It writes only what differs: re-entering the current state changes nothing.
 */
function applyMarkup(el: HTMLElement, stateName: string, parts: TeaserParts) {
  const played = stateName === 'played';
  if (played === el.hasAttribute('data-played')) return;
  // defuss-morph reconciles the children in place: the teaser becomes the content;
  // a template coming back (or one inside the content) gets its content as parsed
  const markup = played ? parts.content : parts.teaser;
  dfDollar(el).morph(markup);
  settleTemplates(el, markup);
  dfDollar(el).attr('data-played', played ? '' : null);
}
/** UI side of setState (AGENTS.md "State API"): the state's markup, from the parts read at init. */
function triggerStateChange(el: HTMLElement, stateName: string) {
  applyMarkup(el, stateName, el._teaser ?? partsOf(el));
}
/** Registry-level API; pass the teaser element explicitly. Unknown names throw. */
export const teaserApi = componentState<HTMLElement>({
  component: 'teaser',
  states: teaserStates,
  apply: (el, state) => triggerStateChange(el, state.name),
  read: (el, state) => ({ name: el.hasAttribute('data-played') ? 'played' : 'default', config: state.config }),
  markup: (el, state) => applyMarkup(el, state.name, partsOf(el)),
});
df$.teaserApi = teaserApi;
df$.teaserStates = teaserStates;
/** after a click the keyboard continues in the content: its first control, else the teaser itself */
function focusContent(el: HTMLElement) {
  const first = dfDollar(el)
    .find('a[href], button, input, select, textarea, iframe, video[controls], [tabindex]:not([tabindex="-1"])')
    .get(0) as HTMLElement | undefined;
  if (first) {
    first.focus({ preventScroll: true });
    return;
  }
  dfDollar(el).attr('tabindex', '-1');
  el.focus({ preventScroll: true });
}
function init() {
  dfDollar('.teaser:not([data-init])').each((_i, node) => {
    const el = node as HTMLElement;
    dfDollar(el).data('init', '');
    // both markups, read once while the teaser still shows its authored state
    el._teaser = partsOf(el);
    // el.store + el.api: `$('#intro').api.setState('played')`
    bindComponent(el, teaserApi, { name: el.hasAttribute('data-played') ? 'played' : 'default', config: {} });
    // the play button - or a click anywhere on the card that is not another control
    dfDollar(el).on('click', (e) => {
      if (el.hasAttribute('data-played')) return;
      const control = (e.target as Element).closest('a, button, input, select, textarea, summary, [contenteditable]');
      if (control && el.contains(control) && !control.matches('.play-button')) return;
      teaserApi.setState(el, 'played');
      focusContent(el);
    });
  });
}
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

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