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

Native basis

<div class="audio" role="group"> holding a native <audio> without controls, the play toggle, a .audio-body (title, .audio-progress scrubber, optional .audio-captions line) and the sound controls.

Web Platform APIs

<audio>HTMLMediaElementTextTrackaria-pressed@container

Classes

.audio.audio-body.audio-title.audio-progress.audio-time.audio-captions.audio-volume.audio-volume-panel.audio-tracks

Data attributes

• data-audio-action - toggle, play, pause, skip (+ data-value seconds), seek (a range), mute, volume (a range), rate (a select, or a button with data-value), captions

• data-audio-output - time, duration, remaining

• data-audio-for="id" - a control (or a container of controls) outside the player drives the player with that id

• <script type="application/json" class="audio-tracks"> - the player's own text tracks by key; two or more turn a captions button into a Dropdown Menu

• .audio-volume - the sound button with a vertical volume in a .audio-volume-panel above it (data-side="bottom": below)

data-variant="compact" - no frame and no title

§Audio

One row: play, the title over the scrubber and its readouts, sound and volume. Open the State tab - playing, time, rate, volume, muted and captions are the player's state.

§Compact

data-variant='compact' drops the frame and the title - the controls alone, for a row in a list or a line of text. The sound button carries the volume as a vertical slider (.audio-volume): its panel opens below the row (data-side='bottom') while the pointer or the focus is on it, so the row stays one line.

§Rate and skip

A select of rates and two skip buttons inside the row - the select shows the rate that plays, whoever set it.

§Controls anywhere on the page

A row outside the player names it once with data-audio-for: its toggle and its readout drive and follow the player. The three buttons call df$.shadcn.audio - each jumps to a sound of the recording and plays from there.

§The API and the store

df$.shadcn.audio plays, pauses, seeks and mutes by name; el.store reports every change - the line under the buttons is written by a store subscriber.

§States

Named states via the shared State API, bound on each .audio:

  • default - paused: at the start, wherever it was paused, or at the end
  • playing - playing; a browser may refuse to start sound without a click on the page (autoplay rules), and the state then stays default

Both take the same config, merged into the current one - only the keys passed change something: time (seek), rate, volume, muted, captions (the key of the text track shown, "" for none). getState() reports them live, with the duration.

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

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

StateTypeValuesDefaultDescription
playingbooleantrue, falsefalseThe audio plays (setState('playing')); unchecked it pauses.
timenumber—0The playback position in seconds - setState(current, { time }) seeks; observed from getState().config.time.
ratenumber—1The playback rate - 1 is normal speed.
volumenumber—1The volume, 0 to 1.
mutedbooleantrue, falsefalseThe sound is off.
captionsstring—""The key of the text track the transcript line shows (a track's id, a <track>'s id or srclang), empty for none - true and false are accepted.

§API

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

States

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

StateDescription
default
Paused - at the start, wherever it was paused, or at the end.
Config fieldTypeDescription
time?numberthe playback position to seek to, seconds; getState() reports the live position
rate?numberthe playback rate - 1 is normal speed, 2 twice as fast
volume?numberthe volume, 0 to 1
muted?booleanthe sound is off
captions?string | booleanthe key of the text track to show (its id, a <track>'s id or srclang - see the captions menu), '' for none; true shows the current or first one, false none. getState() reports the key shown
duration?numberthe audio's length, seconds - reported by getState() (0 until the metadata loaded), never set
playing
Playing. A browser may refuse to start sound without a click on the page (autoplay rules): the state then stays default.
Config fieldTypeDescription
time?numberthe playback position to seek to, seconds; getState() reports the live position
rate?numberthe playback rate - 1 is normal speed, 2 twice as fast
volume?numberthe volume, 0 to 1
muted?booleanthe sound is off
captions?string | booleanthe key of the text track to show (its id, a <track>'s id or srclang - see the captions menu), '' for none; true shows the current or first one, false none. getState() reports the key shown
duration?numberthe audio's length, seconds - reported by getState() (0 until the metadata loaded), never set

Every element

MemberDescription
el.api.setState<S extends AudioState>(name: S, config?: AudioStateConfigs[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?AudioStateConfigs[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: AudioState; config: AudioStateConfigs[AudioState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.

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

el.api.render(state?: { name: AudioState; config: AudioStateConfigs[AudioState]; 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: AudioState; config: AudioStateConfigs[AudioState]; 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: AudioState; config: AudioStateConfigs[AudioState] }>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.audioApi.setState<S extends AudioState>(el: HTMLElement, name: S, config?: AudioStateConfigs[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?AudioStateConfigs[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.audioApi.getState(el: HTMLElement): { name: AudioState; config: AudioStateConfigs[AudioState]; 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: AudioState; config: AudioStateConfigs[AudioState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

df$.shadcn.audioApi.render(state: { name: AudioState; config: AudioStateConfigs[AudioState]; 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: AudioState; config: AudioStateConfigs[AudioState]; model?: ElementModel }a state as getState() returns it (with its model)

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

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

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

df$.shadcn.audioApi.commit<S extends AudioState>(el: HTMLElement, name: S, config?: AudioStateConfigs[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?AudioStateConfigs[S]its config
df$.shadcn.audioStates: AudioState[]The declared states, 'default' first: default, playing.

df$.shadcn.audio

MemberDescription
play(target: string | HTMLElement): Promise<void>
Start playback (the playing state).
ArgumentTypeDescription
targetstring | HTMLElementthe .audio element or its selector

Returns Promise<void> - resolves once playback started, or the browser refused it (the state then reads default)

pause(target: string | HTMLElement): void
Pause (the default state).
ArgumentTypeDescription
targetstring | HTMLElementthe .audio element or its selector
toggle(target: string | HTMLElement): boolean
Play when paused, pause when playing - what a toggle control does.
ArgumentTypeDescription
targetstring | HTMLElementthe .audio element or its selector

Returns boolean - true when it plays now

seek(target: string | HTMLElement, time: number): void
Go to a time; playing stays playing.
ArgumentTypeDescription
targetstring | HTMLElementthe .audio element or its selector
timenumberseconds from the start (clamped to the audio's length)
skip(target: string | HTMLElement, seconds: number): void
Move by some seconds - forward, or back with a negative number.
ArgumentTypeDescription
targetstring | HTMLElementthe .audio element or its selector
secondsnumberhow far to move
mute(target: string | HTMLElement, muted?: boolean): void
Switch the sound off or on.
ArgumentTypeDescription
targetstring | HTMLElementthe .audio element or its selector
muted?booleantrue for off; default: the opposite of now
volume(target: string | HTMLElement, volume: number): void
Set the volume.
ArgumentTypeDescription
targetstring | HTMLElementthe .audio element or its selector
volumenumber0 to 1
rate(target: string | HTMLElement, rate: number): void
Set the playback rate.
ArgumentTypeDescription
targetstring | HTMLElementthe .audio element or its selector
ratenumber1 is normal speed, 0.5 half, 2 twice as fast
captions(target: string | HTMLElement, key?: string | boolean): void
Show a text track by its key, or none.
ArgumentTypeDescription
targetstring | HTMLElementthe .audio element or its selector
key?string | booleanthe track's key, '' or false for none, true for the current or first one; default: none when one shows, else the current or first one
tracks(target: string | HTMLElement, tracks: AudioTextTrack[], replace?: boolean): string[]
Add or update the player's own text tracks by key - while it plays too: a track that shows keeps showing with its new cues. The captions menu lists every track of the audio (a Media Timeline's text rows and <track> elements as well).
ArgumentTypeDescription
targetstring | HTMLElementthe .audio element or its selector
tracksAudioTextTrack[]the tracks, each with its id
replace?booleanalso retire the player's own tracks not listed

Returns string[] - the keys listed

Types

TypeDescription
AudioConfig
The config both states take - every key optional; only the keys passed are applied.
FieldTypeDescription
time?numberthe playback position to seek to, seconds; getState() reports the live position
rate?numberthe playback rate - 1 is normal speed, 2 twice as fast
volume?numberthe volume, 0 to 1
muted?booleanthe sound is off
captions?string | booleanthe key of the text track to show (its id, a <track>'s id or srclang - see the captions menu), '' for none; true shows the current or first one, false none. getState() reports the key shown
duration?numberthe audio's length, seconds - reported by getState() (0 until the metadata loaded), never set
AudioCue
One cue of a text track.
FieldTypeDescription
start?numberwhere it starts, seconds
end?numberwhere it ends, seconds
text?stringthe words
AudioTextTrack
A text track of the player's own - in its script.audio-tracks JSON or handed to tracks(). The same shape as a Media Timeline's text rows.
FieldTypeDescription
idstringits key, unique on the audio: captions "<key>" shows it
kind?stringsubtitles or captions - what is heard (the menu's Subtitles); descriptions - what is seen (its Video captions)
label?stringits name in the captions menu; default: the id
lang?stringits language (BCP 47, "de")
default?booleanshown first when the audio shows no other track yet
itemsAudioCue[]its cues

§CSS view file

The row, the body and its scrubber, the transcript line, the icon swap of the toggles and the compact variant.

/* -- Audio component ------------------------------------------ */
/* A native <audio> (no controls of its own) behind one row: the  */
/* play toggle, a title over the scrubber and its readouts, the   */
/* sound controls - Buttons, Sliders and readouts                 */
/* (data-audio-action / -output). An optional line under the      */
/* scrubber shows the active caption cue, a transcript as it is   */
/* spoken. data-variant="compact" drops the frame and the title.  */
@layer components {
  .audio {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    inline-size: 100%;
    min-inline-size: 0;
    padding: 0.5rem 0.75rem;
    border: 1px solid var(--border);
    border-radius: var(--radius-lg);
    background-color: var(--card);
    color: var(--card-foreground);
    container-type: inline-size;
    & > audio:not([controls]) {
      display: none;
    }
    & > [data-audio-action="volume"] {
      inline-size: 4.5rem;
    }
  }
  .audio-body {
    display: grid;
    flex: 1;
    gap: 0.125rem;
    min-inline-size: 0;
  }
  .audio-title {
    overflow: hidden;
    font-size: 0.875rem;
    font-weight: 500;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .audio-progress {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    min-inline-size: 0;
    & > [data-audio-action="seek"] {
      flex: 1;
      min-inline-size: 3rem;
    }
  }
  /* a readout: m:ss in even digits, so the row does not jitter */
  .audio-time {
    min-inline-size: 2.5rem;
    color: var(--muted-foreground);
    font-family: var(--font-mono);
    font-size: 0.75rem;
    font-variant-numeric: tabular-nums;
    text-align: center;
  }
  /* the active cue - collapses when captions are off or nothing is said */
  .audio-captions {
    margin: 0;
    color: var(--muted-foreground);
    font-size: 0.8125rem;
    font-style: italic;
    text-wrap: pretty;
    &:empty {
      display: none;
    }
  }
  /* a toggle with two icons shows the first when off, the second when on (play / pause, sound / muted) */
  [data-audio-action][aria-pressed="false"] > :nth-child(2),
  [data-audio-action][aria-pressed="true"] > :nth-child(1):not(:only-child) {
    display: none;
  }
  /* a control the page may not use (full screen in a frame without it) - over the Button's own display */
  [data-audio-action][hidden] {
    display: none;
  }
  /* a rate select keeps its own width in the row (a Select is as wide as its box) */
  select[data-audio-action="rate"] {
    flex: none;
    inline-size: auto;
  }
  /* the sound button with a vertical volume in a panel above it (below with data-side="bottom"), shown while the
     pointer or the focus is on either - the row keeps one button's width */
  .audio-volume {
    position: relative;
    display: inline-flex;
  }
  .audio-volume-panel {
    position: absolute;
    inset-block-end: 100%;
    inset-inline-start: 50%;
    z-index: 3;
    display: grid;
    place-items: center;
    margin-block-end: 0.375rem;
    padding: 0.75rem 0.625rem;
    border: 1px solid var(--border);
    border-radius: var(--radius-md);
    background-color: var(--popover);
    box-shadow: var(--shadow-md);
    translate: -50% 0;
    visibility: hidden;
    opacity: 0;
    /* the gap to the button belongs to the hover */
    &::after {
      position: absolute;
      inset-inline: 0;
      inset-block-start: 100%;
      block-size: 0.5rem;
      content: "";
    }
    /* a vertical range's block axis is horizontal: its length is its height */
    & > .slider[data-orientation="vertical"] {
      height: 5.5rem;
    }
    .audio-volume:is(:hover, :focus-within) > & {
      visibility: visible;
      opacity: 1;
    }
    .audio-volume[data-side="bottom"] > & {
      inset-block: 100% auto;
      margin-block: 0.375rem 0;
      &::after {
        inset-block: auto 100%;
      }
    }
  }
  .audio[data-variant="compact"] {
    padding: 0;
    border: 0;
    background-color: transparent;
    & .audio-title {
      display: none;
    }
  }
  /* a narrow player keeps play, the scrubber, the time and the sound */
  @container (inline-size < 22rem) {
    .audio > [data-audio-action="volume"],
    .audio > [data-audio-action="rate"],
    .audio-progress > [data-audio-output="duration"] {
      display: none;
    }
  }
  @media (prefers-reduced-motion: no-preference) {
    .audio-volume-panel {
      transition:
        opacity 0.15s ease,
        visibility 0.15s allow-discrete;
    }
  }
  @media (prefers-contrast: more) {
    .audio-time,
    .audio-captions {
      color: var(--foreground);
    }
  }
  @media (forced-colors: active) {
    .audio,
    .audio-volume-panel {
      border-color: CanvasText;
    }
    .audio-volume-panel {
      background-color: Canvas;
    }
  }
}

§JavaScript view file

The states, the API and the init - the media engine itself is shared with Video in core: src/shared/media.ts.

// -- Audio ---------------------------------------------------
// A stateful audio player over the native <audio>: the states default (paused) and playing, the playback position,
// rate, volume, muted and captions as the state's config, controls inside the player or anywhere on the page
// (data-audio-for), the active caption cue as a transcript line and an imperative API (df$.shadcn.audio). The media
// engine - reading the element, applying a config, the controls, the captions - is shared with the Video component
// (src/shared/media.ts, emitted once in core); this file keeps the states, the API and the init.
// 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,
  bindMedia,
  mediaApply,
  mediaMarkup,
  mediaRead,
  mediaTracks,
} from '../../../shared/state-api.js';
const df$ = defussGlobals();
const dfDollar = defussQuery();
const audioStates = ['default', 'playing'];
/** The config both states take - every key optional; only the keys passed are applied. */
export interface AudioConfig {
  /** the playback position to seek to, seconds; getState() reports the live position */
  time?: number;
  /** the playback rate - 1 is normal speed, 2 twice as fast */
  rate?: number;
  /** the volume, 0 to 1 */
  volume?: number;
  /** the sound is off */
  muted?: boolean;
  /** the key of the text track to show (its id, a <track>'s id or srclang - see the captions menu), '' for none;
   *  true shows the current or first one, false none. getState() reports the key shown */
  captions?: string | boolean;
  /** the audio's length, seconds - reported by getState() (0 until the metadata loaded), never set */
  duration?: number;
}
/** One cue of a text track. */
export interface AudioCue {
  /** where it starts, seconds */
  start?: number;
  /** where it ends, seconds */
  end?: number;
  /** the words */
  text?: string;
}
/** A text track of the player's own - in its script.audio-tracks JSON or handed to tracks(). The same shape as a Media
 *  Timeline's text rows. */
export interface AudioTextTrack {
  /** its key, unique on the audio: captions "<key>" shows it */
  id: string;
  /** subtitles or captions - what is heard (the menu's Subtitles); descriptions - what is seen (its Video captions) */
  kind?: string;
  /** its name in the captions menu; default: the id */
  label?: string;
  /** its language (BCP 47, "de") */
  lang?: string;
  /** shown first when the audio shows no other track yet */
  default?: boolean;
  /** its cues */
  items: AudioCue[];
}
// 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. */
export interface AudioStateConfigs {
  /** Paused - at the start, wherever it was paused, or at the end. */
  default: AudioConfig;
  /** Playing. A browser may refuse to start sound without a click on the page (autoplay rules): the state then stays default. */
  playing: AudioConfig;
}
/** The markup of a state - the toggles' aria-pressed, the volume and rate controls, the audio's muted attribute. */
function applyMarkup(el: HTMLElement, stateName: string, config: AudioConfig): void {
  mediaMarkup(el, 'audio', stateName, config);
}
/** The DOM side of a state: the keys passed are applied to the <audio>, then it plays or pauses. */
function triggerStateChange(
  el: HTMLElement,
  stateName: string,
  config: AudioConfig,
  incoming: Record<string, unknown>,
) {
  return mediaApply(el, 'audio', stateName, config, incoming);
}
/** Registry-level API; pass the .audio element explicitly. Unknown names throw. */
export const audioApi = componentState({
  component: 'audio',
  states: audioStates,
  // the config IS the player's settings: a setState names what changes, the rest stays
  mergeConfig: true,
  apply: (el, state, _previous, incoming) => triggerStateChange(el, state.name, state.config, incoming),
  // reflect reality: the native controls, the media keys and the end of the audio change it without setState()
  read: (el) => {
    const { playing, config } = mediaRead(el);
    const { time, duration, rate, volume, muted, captions } = config;
    return { name: playing ? 'playing' : 'default', config: { time, duration, rate, volume, muted, captions } };
  },
  markup: (el, state) => applyMarkup(el, state.name, state.config),
});
df$.audioApi = audioApi;
df$.audioStates = audioStates;
function init() {
  dfDollar('.audio:not([data-init])')
    .toArray()
    .forEach((el: HTMLElement) => {
      el.dataset.init = '';
      // el.store + el.api (AGENTS.md "State through stores"): $('#my-audio').api.setState('playing')
      bindComponent(el, audioApi);
      bindMedia(el, 'audio', () => audioApi.getState(el));
    });
}
const resolve = (target: string | HTMLElement): HTMLElement | undefined =>
  typeof target === 'string' ? dfDollar(target).get(0) : target;
/** the state a call keeps: playing stays playing */
const now = (el: HTMLElement): string => (mediaRead(el).playing ? 'playing' : 'default');
df$.audio = {
  /**
   * Start playback (the playing state).
   * @param target - the .audio element or its selector
   * @returns resolves once playback started, or the browser refused it (the state then reads default)
   */
  play: async (target: string | HTMLElement): Promise<void> => {
    const el = resolve(target);
    if (el?.api) await el.api.setState('playing');
  },
  /**
   * Pause (the default state).
   * @param target - the .audio element or its selector
   */
  pause: (target: string | HTMLElement): void => {
    resolve(target)?.api?.setState('default');
  },
  /**
   * Play when paused, pause when playing - what a toggle control does.
   * @param target - the .audio element or its selector
   * @returns true when it plays now
   */
  toggle: (target: string | HTMLElement): boolean => {
    const el = resolve(target);
    if (!el?.api) return false;
    const next = now(el) === 'playing' ? 'default' : 'playing';
    el.api.setState(next);
    return next === 'playing';
  },
  /**
   * Go to a time; playing stays playing.
   * @param target - the .audio element or its selector
   * @param time - seconds from the start (clamped to the audio's length)
   */
  seek: (target: string | HTMLElement, time: number): void => {
    const el = resolve(target);
    if (el?.api) el.api.setState(now(el), { time });
  },
  /**
   * Move by some seconds - forward, or back with a negative number.
   * @param target - the .audio element or its selector
   * @param seconds - how far to move
   */
  skip: (target: string | HTMLElement, seconds: number): void => {
    const el = resolve(target);
    if (el?.api) el.api.setState(now(el), { time: mediaRead(el).config.time + seconds });
  },
  /**
   * Switch the sound off or on.
   * @param target - the .audio element or its selector
   * @param muted - true for off; default: the opposite of now
   */
  mute: (target: string | HTMLElement, muted?: boolean): void => {
    const el = resolve(target);
    if (el?.api) el.api.setState(now(el), { muted: muted ?? !mediaRead(el).config.muted });
  },
  /**
   * Set the volume.
   * @param target - the .audio element or its selector
   * @param volume - 0 to 1
   */
  volume: (target: string | HTMLElement, volume: number): void => {
    const el = resolve(target);
    if (el?.api) el.api.setState(now(el), { volume });
  },
  /**
   * Set the playback rate.
   * @param target - the .audio element or its selector
   * @param rate - 1 is normal speed, 0.5 half, 2 twice as fast
   */
  rate: (target: string | HTMLElement, rate: number): void => {
    const el = resolve(target);
    if (el?.api) el.api.setState(now(el), { rate });
  },
  /**
   * Show a text track by its key, or none.
   * @param target - the .audio element or its selector
   * @param key - the track's key, '' or false for none, true for the current or first one; default: none when one
   *   shows, else the current or first one
   */
  captions: (target: string | HTMLElement, key?: string | boolean): void => {
    const el = resolve(target);
    if (el?.api) el.api.setState(now(el), { captions: key ?? (mediaRead(el).config.captions ? '' : true) });
  },
  /**
   * Add or update the player's own text tracks by key - while it plays too: a track that shows keeps showing with
   * its new cues. The captions menu lists every track of the audio (a Media Timeline's text rows and <track>
   * elements as well).
   * @param target - the .audio element or its selector
   * @param tracks - the tracks, each with its id
   * @param replace - also retire the player's own tracks not listed
   * @returns the keys listed
   */
  tracks: (target: string | HTMLElement, tracks: AudioTextTrack[], replace?: boolean): string[] => {
    const el = resolve(target);
    return el?.api ? mediaTracks(el, 'audio', tracks, replace) : [];
  },
};
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

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