TeaserMOL
A card that stands in for content that should not load yet - a slide deck, a video, an embedded app. The content waits in a <template>, where nothing loads or runs; the Play Button - or a click anywhere on the card - morphs it into place through defuss-morph. Wrapped in the Shapes module's aura, the teaser draws the eye until it is played.
On this page (7)
§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-playedon 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:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
played | boolean | true, false | false | The 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.
| State | Description |
|---|---|
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
| Member | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
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.
Returns | |||||||||
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 | |||||||||
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.
Returns | |||||||||
el.api.settled(): Promise<void> | Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting). Returns | |||||||||
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
| Member | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
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.
Returns | ||||||||||||
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.
Returns | ||||||||||||
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.
Returns | ||||||||||||
df$.shadcn.teaserApi.store(el: HTMLElement): Store<{ name: TeaserState; config: TeaserStateConfigs[TeaserState] }> | The element's store (bindComponent made it).
Returns | ||||||||||||
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.
| ||||||||||||
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