Theme
Design your own
On this page (14)
Component Skill — components/chord-chart/component-skill.md

Native basis

<figure class="chord-chart"> with a <figcaption> - the chord's name - and its shape in data-shape. The runtime draws the grid after the caption as a role="img" whose aria-label reads the chord ("C: 0 0 0 3").

Web Platform APIs

<figure>role="img"MutationObserverLogical propertiescolor-mix()

Classes

.chord-chart.chord-chart-grid.chord-chart-dot.chord-chart-mark.chord-chart-barre

Data attributes

• data-shape - a fret per string from the string the tuning names first to the 1st ("0003", "x32010", "x 5 7 7 7 5" once a fret has two digits); x mutes a string

• data-barre - the fret one finger presses across; data-capo - the capo's fret; data-active - the chord sounding

• data-orientation="vertical" - upright, the nut on top; data-hand="right" - mirrored; data-colors="strings" - a chart colour per string

§Chord Chart

C on a ukulele: one finger on the 1st string at the 3rd fret, the other three strings open - nothing is drawn on them. Across, as you see the fretboard looking down at the instrument: the nut on the left (the fretting hand's side), the 1st string on top. Open the State tab to write another name, shape, barre or capo, or to mark it active.

§Upright

data-orientation="vertical": the classic chord chart - the nut on top, the strings from the 4th (G) on the left to the 1st on the right. G7 on a ukulele.

§Right hand

data-hand="right" mirrors the grid for a left-handed player: across, the nut on the right.

§String colours

data-colors="strings": string n takes chart colour n, so a dot reads as its string at a glance.

§Six strings, a muted one

A guitar's C: six frets in the shape, x on the 6th string - an × before the nut.

§Barre

F on a guitar with data-barre="1": one finger lies across all six strings at the 1st fret - a dashed line, and no dots of their own on the barred strings. A barre is never guessed from the frets.

§Higher up the neck

D as an A-shape barre at the 5th fret: a shape above the 4th fret is drawn from its lowest fret, its number named beside the grid (5fr). Frets past 9 make the shape a spaced list.

§Capo

data-capo="2": the shape counts from the capo, as tabs for a capo are written - the G shape sounds as A. The capo is the bold bar, the strings go on behind it.

§Active

The active state (data-active): the chord sounding - its frame, name and dots in the primary colour.

§A chord library

df$.shadcn.chordChart.chord() shows another chord in the chart: pick one from the select. The chart keeps its state - mark it active with the switch, and the next chord stays active.

§States

Named states via the shared State API, bound on each .chord-chart:

  • default - the chart at rest
  • active - the chord sounding (data-active): its frame, name and dots in the primary colour

Every state takes { name, shape, barre, capo }, merged into the current config: the caption, data-shape, data-barre and data-capo. getState() reports what the chart shows, also what was written into its markup - the Fretboard marks the chart of the chord sounding through data-active.

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

StateTypeValuesDefaultDescription
activebooleantrue, falsefalseThe chord sounding (setState('active')): frame, name and dots in the primary colour.
namestring——The chord's name, the caption; observed from getState().config.name.
shapestring——A fret per string, x muted; observed from getState().config.shape.
barrenumber——The fret a barre lies on (empty: none); observed from getState().config.barre.
caponumber—0The capo's fret (0: none); observed from getState().config.capo.

§API

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

States

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

StateDescription
default
The chart at rest.
Config fieldTypeDescription
name?stringthe chord's name - the caption
shape?stringthe shape: a fret per string, from the string the tuning names first (a ukulele's G) to the 1st - "0003", "x32010", "x 10 12 12 12 10" for frets past 9; x mutes a string
barre?number | nullthe fret a barre lies on (one finger across the strings pressed there), null for none
capo?numberthe capo's fret, 0 for none
active
The chord sounding now (data-active): its frame, caption and dots in the primary colour.
Config fieldTypeDescription
name?stringthe chord's name - the caption
shape?stringthe shape: a fret per string, from the string the tuning names first (a ukulele's G) to the 1st - "0003", "x32010", "x 10 12 12 12 10" for frets past 9; x mutes a string
barre?number | nullthe fret a barre lies on (one finger across the strings pressed there), null for none
capo?numberthe capo's fret, 0 for none

Every element

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

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

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

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

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

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

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

df$.shadcn.chordChartApi.commit<S extends ChordChartState>(el: HTMLElement, name: S, config?: ChordChartStateConfigs[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?ChordChartStateConfigs[S]its config
df$.shadcn.chordChartStates: ChordChartState[]The declared states, 'default' first: default, active.

df$.shadcn.chordChart

MemberDescription
chord(target: string | HTMLElement, chord?: ChordChartConfig): Required<ChordChartConfig> | null
The chord a chart shows, or show another (setState in its current state - the rest of the chord stays).
ArgumentTypeDescription
targetstring | HTMLElementthe .chord-chart element or a selector of it
chord?ChordChartConfigthe name, shape, barre and capo to show; leave it out to read

Returns Required<ChordChartConfig> | null - the chord now; null for an unknown element

parse(shape: string): ChordChartShape | null
Read a shape's text.
ArgumentTypeDescription
shapestring"0003", "x32010" or "x 10 12 12 12 10"

Returns ChordChartShape | null - each string's fret (x muted), from the string the tuning names first to the 1st; null for a shape that is none

Types

TypeDescription
ChordChartConfig
The config every state takes.
FieldTypeDescription
name?stringthe chord's name - the caption
shape?stringthe shape: a fret per string, from the string the tuning names first (a ukulele's G) to the 1st - "0003", "x32010", "x 10 12 12 12 10" for frets past 9; x mutes a string
barre?number | nullthe fret a barre lies on (one finger across the strings pressed there), null for none
capo?numberthe capo's fret, 0 for none
ChordChartShape
A shape read: each string's fret (x for a muted one), from the string the tuning names first to the 1st.

= (number | 'x')[]

§CSS view file

The figure and its caption, the active frame, the grid (strings and fret wires as two gradients, the nut, a capo's bar with the strings behind it, the first fret's number), the dots, the × and the barre, the upright and mirrored grid, the string colours and the accessibility media queries.

/* -- Chord Chart ------------------------------------------------
   One chord diagram: the figure (its caption, the frame of the active state) and the grid the runtime draws from
   data-shape - strings and fret wires as two gradients, the dots, the × of a muted string, a barre's dashed line, a
   capo's bold bar with the strings going on behind it. Across by default, as the player sees the fretboard (the nut
   on the fretting hand's side, the 1st string on top); upright with data-orientation="vertical". */
@layer components {
  .chord-chart {
    display: inline-grid;
    justify-items: center;
    gap: 0.25rem;
    margin: 0;
    padding: 0.375rem 0.625rem 0.5rem;
    border: 1px solid transparent;
    border-radius: var(--radius-md);
    color: var(--foreground);
    font-family: var(--font-sans);
    transition:
      background-color 120ms ease,
      border-color 120ms ease;
    & figcaption {
      font-size: 0.8125rem;
      font-weight: 700;
    }
    /* the chord sounding */
    &[data-active] {
      border-color: var(--primary);
      background-color: color-mix(in oklab, var(--primary) 10%, transparent);
      & figcaption {
        color: var(--primary);
      }
      &:not([data-colors]) .chord-chart-dot {
        background-color: var(--primary);
      }
      & .chord-chart-barre {
        border-color: var(--primary);
      }
    }
  }
  /* across (default): the nut on the fretting hand's side, the frets away from it, the strings from the 1st at the
     top; × before the nut on a muted string (nothing on an open one), a shape higher up names its first fret below */
  .chord-chart-grid {
    --chord-chart-strings: 4;
    --chord-chart-frets: 4;
    --chord-chart-tx: -50%;
    position: relative;
    inline-size: calc(var(--chord-chart-frets) * 1rem);
    block-size: calc((var(--chord-chart-strings) - 1) * 0.75rem);
    margin: 0.375rem 0.75rem 0.25rem;
    border-inline-start: 3px solid var(--foreground);
    background-image:
      linear-gradient(to right, var(--muted-foreground) 1px, transparent 1px),
      linear-gradient(to bottom, var(--muted-foreground) 1px, transparent 1px);
    background-size:
      calc((100% - 1px) / var(--chord-chart-frets)) 100%,
      100% calc((100% - 1px) / (var(--chord-chart-strings) - 1));
    /* a capo: the bold bar is the capo, the strings go on behind it to the fret wire before it */
    &[data-capo] {
      --chord-chart-behind: 1rem;
      margin-inline-start: calc(0.75rem + var(--chord-chart-behind));
      border-inline-start-width: 4px;
      &::before {
        position: absolute;
        inset-block: 0;
        inset-inline-end: calc(100% + 4px);
        inline-size: var(--chord-chart-behind);
        border-inline-start: 1px solid var(--muted-foreground);
        background-image: linear-gradient(to bottom, var(--muted-foreground) 1px, transparent 1px);
        background-size: 100% calc((100% - 1px) / (var(--chord-chart-strings) - 1));
        content: "";
      }
    }
    &[data-base] {
      margin-block-end: 1rem;
      border-inline-start-width: 1px;
      &::after {
        position: absolute;
        inset-block-start: calc(100% + 0.25rem);
        inset-inline-start: calc((100% - 1px) * 0.5 / var(--chord-chart-frets));
        color: var(--muted-foreground);
        content: attr(data-base) "fr";
        font-size: 0.625rem;
        line-height: 1;
        translate: var(--chord-chart-tx) 0;
      }
    }
  }
  .chord-chart-dot,
  .chord-chart-mark {
    position: absolute;
    inset-block-start: calc(
      (100% - 1px) * (var(--chord-chart-strings) - 1 - var(--chord-chart-i, 0)) / (var(--chord-chart-strings) - 1) +
        0.5px
    );
    translate: var(--chord-chart-tx) -50%;
  }
  .chord-chart-dot {
    inset-inline-start: calc((100% - 1px) * (var(--chord-chart-r, 0) + 0.5) / var(--chord-chart-frets));
    inline-size: 0.625rem;
    block-size: 0.625rem;
    border-radius: 50%;
    background-color: var(--chord-chart-color, var(--foreground));
  }
  .chord-chart-mark {
    inset-inline-start: calc(-0.625rem - var(--chord-chart-behind, 0rem));
    color: var(--chord-chart-color, var(--muted-foreground));
    font-size: 0.5625rem;
    line-height: 1;
  }
  /* a barre: one finger across its strings, a dashed line through them */
  .chord-chart-barre {
    position: absolute;
    inset-block-start: calc(
      (100% - 1px) * (var(--chord-chart-strings) - 1 - var(--chord-chart-j, 0)) / (var(--chord-chart-strings) - 1) +
        0.5px - 0.3125rem
    );
    inset-inline-start: calc((100% - 1px) * (var(--chord-chart-r, 0) + 0.5) / var(--chord-chart-frets));
    inline-size: 0;
    block-size: calc(
      (100% - 1px) * (var(--chord-chart-j, 0) - var(--chord-chart-i, 0)) / (var(--chord-chart-strings) - 1) + 0.625rem
    );
    border-inline-start: 3px dashed var(--foreground);
    translate: var(--chord-chart-tx) 0;
  }
  /* upright (data-orientation="vertical"), the classic chord chart: the nut on top, the strings from the
     highest-numbered on the left; × above the nut, a shape higher up names its first fret beside it */
  .chord-chart[data-orientation="vertical"] .chord-chart-grid {
    inline-size: calc((var(--chord-chart-strings) - 1) * 1rem);
    block-size: calc(var(--chord-chart-frets) * 0.875rem);
    margin: 0.75rem 0 0;
    border-inline-start: 0;
    border-block-start: 3px solid var(--foreground);
    background-size:
      calc((100% - 1px) / (var(--chord-chart-strings) - 1)) 100%,
      100% calc((100% - 1px) / var(--chord-chart-frets));
    &[data-capo] {
      margin: calc(0.75rem + var(--chord-chart-behind)) 0 0;
      border-block-start-width: 4px;
      &::before {
        inset-block: auto calc(100% + 4px);
        inset-inline: 0;
        inline-size: auto;
        block-size: var(--chord-chart-behind);
        border-inline-start: 0;
        border-block-start: 1px solid var(--muted-foreground);
        background-image: linear-gradient(to right, var(--muted-foreground) 1px, transparent 1px);
        background-size: calc((100% - 1px) / (var(--chord-chart-strings) - 1)) 100%;
      }
    }
    &[data-base] {
      border-block-start-width: 1px;
      &::after {
        inset-block-start: 0.4375rem;
        inset-inline-start: calc(100% + 0.25rem);
        translate: 0 -50%;
      }
    }
    .chord-chart-dot,
    .chord-chart-mark {
      inset-inline-start: calc((100% - 1px) * var(--chord-chart-i, 0) / (var(--chord-chart-strings) - 1) + 0.5px);
    }
    .chord-chart-dot {
      inset-block-start: calc((100% - 1px) * (var(--chord-chart-r, 0) + 0.5) / var(--chord-chart-frets));
    }
    .chord-chart-mark {
      inset-block-start: calc(-0.5rem - var(--chord-chart-behind, 0rem));
    }
    .chord-chart-barre {
      inset-block-start: calc((100% - 1px) * (var(--chord-chart-r, 0) + 0.5) / var(--chord-chart-frets));
      inset-inline-start: calc(
        (100% - 1px) * var(--chord-chart-i, 0) / (var(--chord-chart-strings) - 1) + 0.5px - 0.3125rem
      );
      inline-size: calc(
        (100% - 1px) * (var(--chord-chart-j, 0) - var(--chord-chart-i, 0)) / (var(--chord-chart-strings) - 1) + 0.625rem
      );
      block-size: 0;
      border-inline-start: 0;
      border-block-start: 3px dashed var(--foreground);
      translate: 0 -50%;
    }
  }
  /* for the right hand, mirrored: across the nut on the right, upright the strings 1st to highest from the left */
  .chord-chart[data-hand="right"] .chord-chart-grid {
    --chord-chart-tx: 50%;
    direction: rtl;
  }
  /* string colours (data-colors="strings"): string n takes chart colour n, from the 6th on they start over */
  .chord-chart[data-colors="strings"] {
    & :is([data-string="1"], [data-string="6"], [data-string="11"]) {
      --chord-chart-color: var(--chart-1);
    }
    & :is([data-string="2"], [data-string="7"], [data-string="12"]) {
      --chord-chart-color: var(--chart-2);
    }
    & :is([data-string="3"], [data-string="8"]) {
      --chord-chart-color: var(--chart-3);
    }
    & :is([data-string="4"], [data-string="9"]) {
      --chord-chart-color: var(--chart-4);
    }
    & :is([data-string="5"], [data-string="10"]) {
      --chord-chart-color: var(--chart-5);
    }
    /* a × in the string's colour mixed toward the text colour - legible in either mode */
    & .chord-chart-mark {
      color: color-mix(in oklab, var(--chord-chart-color, var(--foreground)) 55%, var(--foreground));
      font-weight: 700;
    }
  }
  @media (prefers-reduced-motion: reduce) {
    .chord-chart {
      transition: none;
    }
  }
  @media (prefers-contrast: more) {
    .chord-chart-grid {
      background-image:
        linear-gradient(to right, var(--foreground) 1px, transparent 1px),
        linear-gradient(to bottom, var(--foreground) 1px, transparent 1px);
    }
  }
  @media (forced-colors: active) {
    .chord-chart[data-active] {
      border-color: Highlight;
      & .chord-chart-dot {
        background-color: Highlight;
        forced-color-adjust: none;
      }
    }
    .chord-chart-dot {
      background-color: CanvasText;
      forced-color-adjust: none;
    }
  }
}

§JavaScript view file

The shape's parser, the barre a shape can hold, the grid drawn from the markup, the State API and df$.shadcn.chordChart.

// -- Chord Chart -------------------------------------------------
// One chord diagram of a fretted instrument from its markup: the shape (a fret per string), the barre the data names,
// a capo. Drawn across as the player sees the fretboard (the nut on the fretting hand's side, the 1st string on top)
// or upright (the classic chart: the nut on top), for either hand: dots where fingers go, nothing on an open string,
// × on a muted one, a barre as a dashed line, the first fret's number when the shape sits higher up the neck, a capo
// as the bold bar with the strings going on behind it. The Fretboard writes one per chord into its chords outlet and
// marks the one sounding (the active state).
// 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 } from '../../../shared/state-api.js';
const df$ = defussGlobals();
const dfDollar = defussQuery();
const chordChartStates = ['default', 'active'];
/** The config every state takes. */
export interface ChordChartConfig {
  /** the chord's name - the caption */
  name?: string;
  /** the shape: a fret per string, from the string the tuning names first (a ukulele's G) to the 1st - "0003", "x32010", "x 10 12 12 12 10" for frets past 9; x mutes a string */
  shape?: string;
  /** the fret a barre lies on (one finger across the strings pressed there), null for none */
  barre?: number | null;
  /** the capo's fret, 0 for none */
  capo?: number;
}
// 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 ChordChartStateConfigs {
  /** The chart at rest. */
  default: ChordChartConfig;
  /** The chord sounding now (data-active): its frame, caption and dots in the primary colour. */
  active: ChordChartConfig;
}
/** A shape read: each string's fret (x for a muted one), from the string the tuning names first to the 1st. */
export type ChordChartShape = (number | 'x')[];
type Fret = number | 'x';
type Barre = { fret: number; from: number; to: number };
const clamp = (v: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, v));
const esc = (s: unknown): string =>
  String(s).replace(
    /[&<>"']/g,
    (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] ?? c,
  );
/** a shape from its text: one character per string, or a space- / comma-separated list once a fret has two digits */
function parseShape(text: string | null | undefined): Fret[] | null {
  const s = String(text ?? '').trim();
  if (!s) return null;
  const tokens = /[\s,]/.test(s) ? s.split(/[\s,]+/).filter(Boolean) : [...s];
  const out: Fret[] = [];
  for (const t of tokens) {
    if (t === 'x' || t === 'X') out.push('x');
    else if (/^\d{1,2}$/.test(t) && Number(t) <= 24) out.push(Number(t));
    else return null;
  }
  return out.length >= 1 && out.length <= 12 ? out : null;
}
/** a shape as text: characters while every fret has one digit, else a spaced list */
const shapeText = (shape: Fret[]): string =>
  shape.some((f) => typeof f === 'number' && f > 9) ? shape.join(' ') : shape.join('');
/** a barre on a fret: the strings from the first to the last pressed there, every string between pressed there or
 *  higher - a barre the shape cannot hold is none */
function barreOf(shape: Fret[], fret: number): Barre | null {
  if (!(fret > 0)) return null;
  const at = shape.map((f, i) => (f === fret ? i : -1)).filter((i) => i >= 0);
  if (at.length < 2) return null;
  const from = at[0];
  const to = at[at.length - 1];
  for (let i = from; i <= to; i++) if (typeof shape[i] !== 'number' || (shape[i] as number) < fret) return null;
  return { fret, from, to };
}
const numAttr = (el: Element, name: string): number | null => {
  const v = el.getAttribute(name);
  return v === null || v === '' || !Number.isFinite(Number(v)) ? null : Number(v);
};
const captionOf = (el: Element): string => dfDollar(el).children('figcaption').text().trim();
/** the chart's grid: four frets or more, the first fret's number when the shape sits higher up (above the 4th) */
function gridMarkup(el: Element): string {
  const name = captionOf(el);
  const shape = parseShape(el.getAttribute('data-shape'));
  const n = shape?.length ?? Math.max(1, numAttr(el, 'data-strings') ?? 4);
  const played = (shape ?? []).filter((f): f is number => typeof f === 'number' && f > 0);
  const top = Math.max(0, ...played);
  const base = top > 4 ? Math.min(...played) : 1;
  const rows = clamp(top - base + 1, 4, 6);
  const barre = shape ? barreOf(shape, numAttr(el, 'data-barre') ?? 0) : null;
  const barred = (f: Fret, i: number) => !!barre && f === barre.fret && i >= barre.from && i <= barre.to;
  const marks = (shape ?? [])
    .map((f, i) =>
      f === 'x'
        ? `<span class="chord-chart-mark" data-string="${n - i}" style="--chord-chart-i: ${i}">×</span>`
        : f === 0 || barred(f, i)
          ? ''
          : `<span class="chord-chart-dot" data-string="${n - i}" style="--chord-chart-i: ${i}; --chord-chart-r: ${f - base}"></span>`,
    )
    .join('');
  const bar = barre
    ? `<span class="chord-chart-barre" style="--chord-chart-i: ${barre.from}; --chord-chart-j: ${barre.to}; --chord-chart-r: ${barre.fret - base}"></span>`
    : '';
  const capo = clamp(Math.round(numAttr(el, 'data-capo') ?? 0), 0, 12);
  const label = shape
    ? `${name ? `${name}: ` : ''}${shape.join(' ')}${barre ? `, barre at fret ${barre.fret}` : ''}${capo ? `, capo ${capo}` : ''}`
    : name;
  return `<div class="chord-chart-grid" role="img" aria-label="${esc(label)}" style="--chord-chart-strings: ${n}; --chord-chart-frets: ${rows}"${base > 1 ? ` data-base="${base}"` : ''}${capo && base === 1 ? ` data-capo="${capo}"` : ''}>${bar}${marks}</div>`;
}
/** Draw the grid again (another shape, barre, capo or name) - after the caption. */
function draw(el: HTMLElement): void {
  const markup = gridMarkup(el);
  // a morph of the markup around it (the Fretboard writing its chords outlet again) keeps the chart, not its grid
  const old = dfDollar(el).children('.chord-chart-grid');
  if (el._chordChart === markup && old.length) return;
  el._chordChart = markup;
  const grid = dfDollar(markup).get(0) as HTMLElement;
  if (old.length) old.replaceWith(grid);
  else {
    const caption = dfDollar(el).children('figcaption');
    if (caption.length) caption.after(grid);
    else dfDollar(el).append(grid);
  }
}
/** the markup of a state: active is data-active; the config's name, shape, barre and capo are the caption and the
 *  data attributes */
function applyMarkup(el: Element, stateName: string, config: ChordChartConfig): void {
  const active = stateName === 'active' ? '' : null;
  if (el.getAttribute('data-active') !== active) dfDollar(el).attr('data-active', active);
  if (typeof config.name === 'string') {
    const caption = dfDollar(el).children('figcaption');
    if (!caption.length) dfDollar(el).prepend(dfDollar(`<figcaption>${esc(config.name)}</figcaption>`));
    else if (caption.text() !== config.name) caption.text(config.name);
  }
  if (typeof config.shape === 'string') {
    const parsed = parseShape(config.shape);
    const text = parsed ? shapeText(parsed) : null;
    // the authored text stays while it means the same shape ("x 5 7 7 7 5" is x57775)
    const now = parseShape(el.getAttribute('data-shape'));
    if ((now ? shapeText(now) : null) !== text) dfDollar(el).attr('data-shape', text);
  }
  if (config.barre !== undefined) {
    const v = config.barre === null || !(Number(config.barre) > 0) ? null : String(Math.round(Number(config.barre)));
    if (el.getAttribute('data-barre') !== v) dfDollar(el).attr('data-barre', v);
  }
  if (config.capo !== undefined) {
    const c = clamp(Math.round(Number(config.capo) || 0), 0, 12);
    const v = c ? String(c) : null;
    if (el.getAttribute('data-capo') !== v) dfDollar(el).attr('data-capo', v);
  }
}
/** what the chart shows, read from its markup */
const configOf = (el: Element): Required<ChordChartConfig> => ({
  name: captionOf(el),
  shape: el.getAttribute('data-shape') ?? '',
  barre: numAttr(el, 'data-barre'),
  capo: numAttr(el, 'data-capo') ?? 0,
});
/** UI side of setState: the state's markup, the grid drawn again. */
function triggerStateChange(el: HTMLElement, stateName: string, incoming: ChordChartConfig): undefined {
  applyMarkup(el, stateName, incoming);
  if (el._chordChart !== undefined) draw(el);
  return undefined;
}
/** Registry-level API; pass the .chord-chart element explicitly. Unknown names throw. */
export const chordChartApi = componentState({
  component: 'chord-chart',
  states: chordChartStates,
  // the config is the chord: a state change keeps it unless it names another
  mergeConfig: true,
  apply: (el, state, _previous, incoming) => triggerStateChange(el, state.name, incoming as ChordChartConfig),
  // data-active is the active state - the Fretboard writes it on the chart of the chord sounding
  read: (el, state) => {
    const name = el.hasAttribute('data-active') ? 'active' : 'default';
    const now = configOf(el);
    const c = state.config as ChordChartConfig;
    if (
      name === state.name &&
      c.name === now.name &&
      c.shape === now.shape &&
      c.barre === now.barre &&
      c.capo === now.capo
    )
      return state;
    return { name, config: now as Record<string, unknown> };
  },
  markup: (el, state) => applyMarkup(el, state.name, state.config as ChordChartConfig),
});
df$.chordChartApi = chordChartApi;
df$.chordChartStates = chordChartStates;
function init() {
  dfDollar('.chord-chart:not([data-init])')
    .toArray()
    .forEach((el: HTMLElement) => {
      el.dataset.init = '';
      draw(el);
      // el.store + el.api (AGENTS.md "State through stores"): $('#c').api.setState('active')
      bindComponent(el, chordChartApi, {
        name: el.hasAttribute('data-active') ? 'active' : 'default',
        config: configOf(el),
      });
      // another shape, barre, capo or name written into the markup: drawn again
      new MutationObserver(() => draw(el)).observe(el, {
        attributes: true,
        attributeFilter: ['data-shape', 'data-barre', 'data-capo', 'data-strings'],
        childList: true,
        characterData: true,
        subtree: true,
      });
    });
}
/** the chart of a target: the .chord-chart element or a selector of it */
const resolve = (target: string | HTMLElement): HTMLElement | undefined => {
  const el = typeof target === 'string' ? dfDollar(target).get(0) : target;
  const chart = el?.closest?.<HTMLElement>('.chord-chart');
  return chart?.api ? chart : undefined;
};
df$.chordChart = {
  /**
   * The chord a chart shows, or show another (setState in its current state - the rest of the chord stays).
   * @param target - the .chord-chart element or a selector of it
   * @param chord - the name, shape, barre and capo to show; leave it out to read
   * @returns the chord now; null for an unknown element
   */
  chord: (target: string | HTMLElement, chord?: ChordChartConfig): Required<ChordChartConfig> | null => {
    const el = resolve(target);
    if (!el) return null;
    if (chord)
      el.api?.setState(el.hasAttribute('data-active') ? 'active' : 'default', chord as Record<string, unknown>);
    return configOf(el);
  },
  /**
   * Read a shape's text.
   * @param shape - "0003", "x32010" or "x 10 12 12 12 10"
   * @returns each string's fret (x muted), from the string the tuning names first to the 1st; null for a shape that is none
   */
  parse: (shape: string): ChordChartShape | null => parseShape(shape),
};
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

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