Theme
Design your own
On this page (13)
Component Skill — components/fretboard-neck/component-skill.md

Native basis

<div class="fretboard-neck"> with the instrument and the fingers as data attributes. The runtime draws .fretboard-neck-board inside it (aria-hidden) and a hidden description of what it shows; the root becomes a role="img" described by it.

Web Platform APIs

role="img"aria-describedbyMutationObserverLogical propertiescolor-mix()

Classes

.fretboard-neck.fretboard-neck-board.fretboard-neck-string.fretboard-neck-dot.fretboard-neck-capo.fretboard-neck-barre

Data attributes

• data-tuning - the strings from the highest-numbered to the 1st ("G C E A"); data-strings - without a tuning; data-frets - the frets shown

• data-shape + data-barre - a chord's shape, faint; data-notes - sounding ("3/2 2/3": string/fret); data-next - coming next; data-capo - the capo's fret

• data-orientation="vertical" - upright; data-hand="right" - mirrored; data-colors="strings" - a chart colour per string

• data-fretboard-for="id" - bound to a Fretboard, which sets the neck as it plays

§Fretboard Neck

A ukulele's neck in G C E A with the C shape on it: its one finger, on the 1st string at the 3rd fret, is sounding - filled, with its fret. The open strings show nothing. Open the State tab to put another shape, notes sounding, notes next, a barre or a capo on the neck.

§Notes next

The G shape (0232) faint, the note sounding filled - the 3rd string at the 2nd fret - and the one coming next outlined, the 2nd string at the 3rd fret: what a picking pattern plays now and then.

§Upright

data-orientation="vertical": the neck stood up as a chord chart draws it - the nut on top, the strings from the 4th (G) on the left. F (2010) with its 2nd string sounding.

§Right hand

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

§String colours

data-colors="strings": each string and its dots in a chart colour, the 1st in --chart-1.

§Guitar with a capo

Six strings (E A D G B e) and a capo on the 3rd fret: its bold bar on the 3rd fret wire, the nut a thin line behind it. The F shape counts from the capo, as tabs for a capo are written - its barre (data-barre="1") lies at the capo's 1st fret, the 6th string sounding there.

§Muted strings

A guitar's C (x32010): × at the nut on the muted 6th string, the strummed chord sounding - every fretted string filled with its fret, the open ones nothing.

§A scale

data-frets="12" shows twelve frets, every inlay up to the double dot at the 12th. The C major scale in the first position as notes sounding, no shape - its open C, E and A strings show nothing.

§Play it through the API

df$.shadcn.fretboardNeck.play() puts the notes sounding on the neck, with a shape and the notes next: Strum plays each chord's fretted strings; Pick walks through them one string at a time, the next string outlined.

§States

Named states via the shared State API, bound on each .fretboard-neck:

  • default - the neck with the fingers its config puts on it

The state takes { shape, barre, notes, next, capo }, merged into the current config - notes and next as "string/fret" text or a list of { string, fret }. getState() reports what the neck shows, also what a bound Fretboard or the markup set.

The first example carries data-state-demo; bun run screenshots captures screenshots/{mode}/fretboard-neck.png.

StateTypeValuesDefaultDescription
shapestring——A chord's shape, drawn faint; observed from getState().config.shape.
notesstring——The notes sounding, "string/fret" pairs; observed from getState().config.notes.
nextstring——The notes coming next, outlined; observed from getState().config.next.
barrenumber——The fret the shape's 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 fretboard-neck.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.

States

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

StateDescription
default
The neck with the fingers the config puts on it.
Config fieldTypeDescription
shape?stringa chord's shape, drawn faint: a fret per string from the string the tuning names first to the 1st ("0003", "x32010", "x 10 12 12 12 10"); "" for none
barre?number | nullthe fret the shape's barre lies on, null for none
notes?string | FretboardNeckNote[]the notes sounding, drawn filled with their fret: "string/fret" pairs ("3/2 2/3"; string 1 is the highest), or a list of { string, fret }
next?string | FretboardNeckNote[]the notes coming next, drawn outlined - as notes
capo?numberthe capo's fret, 0 for none; the frets of the shape and the notes count from it

Every element

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

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

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

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

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

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

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

df$.shadcn.fretboardNeckApi.commit<S extends FretboardNeckState>(el: HTMLElement, name: S, config?: FretboardNeckStateConfigs[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?FretboardNeckStateConfigs[S]its config
df$.shadcn.fretboardNeckStates: FretboardNeckState[]The declared states, 'default' first: default.

df$.shadcn.fretboardNeck

MemberDescription
play(target: string | HTMLElement, notes: string | FretboardNeckNote[], config: FretboardNeckConfig = {}): FretboardNeckNote[]
Put fingers on the neck - the notes sounding, and optionally a shape and the notes next (setState; what it does not name stays).
ArgumentTypeDescription
targetstring | HTMLElementthe .fretboard-neck element or a selector of it
notesstring | FretboardNeckNote[]the notes sounding: "3/2 2/3" or a list of { string, fret }; [] or "" for none
configFretboardNeckConfig = {}the shape, barre, next notes or capo to show with them

Returns FretboardNeckNote[] - the notes now, as { string, fret }; [] for an unknown element

Types

TypeDescription
FretboardNeckConfig
The config the state takes.
FieldTypeDescription
shape?stringa chord's shape, drawn faint: a fret per string from the string the tuning names first to the 1st ("0003", "x32010", "x 10 12 12 12 10"); "" for none
barre?number | nullthe fret the shape's barre lies on, null for none
notes?string | FretboardNeckNote[]the notes sounding, drawn filled with their fret: "string/fret" pairs ("3/2 2/3"; string 1 is the highest), or a list of { string, fret }
next?string | FretboardNeckNote[]the notes coming next, drawn outlined - as notes
capo?numberthe capo's fret, 0 for none; the frets of the shape and the notes count from it
FretboardNeckNote
A note on the neck.
FieldTypeDescription
stringnumberthe string, 1 = the highest
fretnumberthe fret, 0 = open (nothing is drawn on an open string)

§CSS view file

The scroll box, the board (strings and their names, the nut, the fret wires as a gradient, a capo's bar with the nut thin behind it), the inlays and fret numbers, the barre, the dots by role, the upright and mirrored neck, the string colours and the accessibility media queries.

/* -- Fretboard Neck ---------------------------------------------
   The neck of a fretted instrument: the runtime draws .fretboard-neck-board from the markup - the strings and their
   names, the nut on the fretting hand's side (a capo's bold bar on its fret wire, the nut a thin line behind it), the
   fret wires as a gradient, the inlays and fret numbers, a barre's dashed line and the dots of the fingers. Across
   (the 1st string on top, as a tab draws it) or upright (data-orientation="vertical", as a chord chart draws it);
   data-hand="right" mirrors either. The root scrolls when the neck is wider than its column. */
@layer components {
  .fretboard-neck {
    display: block;
    max-inline-size: 100%;
    overflow-x: auto;
    color: var(--foreground);
    scrollbar-width: thin;
  }
  .fretboard-neck-board {
    --fretboard-neck-nut: 1.75rem;
    --fretboard-neck-gap: 1.375rem;
    --fretboard-neck-fret-size: 2rem;
    --fretboard-neck-strings: 4;
    --fretboard-neck-frets: 12;
    /* centring an element on its point: half its width toward the start of the line */
    --fretboard-neck-tx: -50%;
    position: relative;
    min-inline-size: calc(var(--fretboard-neck-nut) + var(--fretboard-neck-frets) * 2.25rem);
    block-size: calc(var(--fretboard-neck-strings) * var(--fretboard-neck-gap));
    margin-block: 0.25rem 1.25rem;
    margin-inline: 1.25rem 0.25rem;
    direction: ltr;
  }
  .fretboard-neck[data-hand="right"] .fretboard-neck-board {
    --fretboard-neck-tx: 50%;
    direction: rtl;
  }
  .fretboard-neck-string {
    position: absolute;
    inset-inline: 0;
    inset-block-start: calc((var(--fretboard-neck-s, 1) - 0.5) * var(--fretboard-neck-gap));
    block-size: 1.5px;
    background-color: var(--fretboard-neck-color, var(--muted-foreground));
    translate: 0 -50%;
  }
  .fretboard-neck-string-name {
    position: absolute;
    inset-inline: -1.25rem auto;
    inset-block-start: calc((var(--fretboard-neck-s, 1) - 0.5) * var(--fretboard-neck-gap));
    color: var(--fretboard-neck-color, var(--muted-foreground));
    font-family: var(--font-mono);
    font-size: 0.6875rem;
    line-height: 1;
    translate: 0 -50%;
  }
  /* the fret wires, after the nut */
  .fretboard-neck-wires {
    position: absolute;
    inset-block: calc(var(--fretboard-neck-gap) / 2 - 1px);
    inset-inline: var(--fretboard-neck-nut) 0;
    border-inline-start: 4px solid var(--foreground);
    border-radius: 1px;
    background-image: linear-gradient(to left, var(--border) 2px, transparent 2px);
    background-size: calc(100% / var(--fretboard-neck-frets)) 100%;
  }
  /* a capo: the bold bar moves to its fret wire, the nut stays a thin line behind it */
  .fretboard-neck[data-capo] .fretboard-neck-wires {
    border-inline-start-width: 2px;
  }
  .fretboard-neck-capo {
    position: absolute;
    inset-block: calc(var(--fretboard-neck-gap) / 2 - 4px);
    inset-inline-start: calc(
      var(--fretboard-neck-nut) + (100% - var(--fretboard-neck-nut)) * var(--fretboard-neck-f, 1) /
        var(--fretboard-neck-frets)
    );
    inline-size: 5px;
    border-radius: 2px;
    background-color: var(--foreground);
    translate: var(--fretboard-neck-tx) 0;
  }
  /* a barre of the shape: one finger across its strings, a dashed line through them */
  .fretboard-neck-barre {
    position: absolute;
    inset-block-start: calc((var(--fretboard-neck-s1, 1) - 0.5) * var(--fretboard-neck-gap) - 0.5rem);
    inset-inline-start: calc(
      var(--fretboard-neck-nut) + (100% - var(--fretboard-neck-nut)) * (var(--fretboard-neck-f, 1) - 0.5) /
        var(--fretboard-neck-frets)
    );
    inline-size: 0;
    block-size: calc((var(--fretboard-neck-s2, 1) - var(--fretboard-neck-s1, 1)) * var(--fretboard-neck-gap) + 1rem);
    border-inline-start: 3px dashed var(--muted-foreground);
    translate: var(--fretboard-neck-tx) 0;
  }
  .fretboard-neck-inlay,
  .fretboard-neck-fret-number,
  .fretboard-neck-dot {
    position: absolute;
    inset-inline-start: calc(
      var(--fretboard-neck-nut) + (100% - var(--fretboard-neck-nut)) * (var(--fretboard-neck-f, 1) - 0.5) /
        var(--fretboard-neck-frets)
    );
    translate: var(--fretboard-neck-tx) -50%;
  }
  .fretboard-neck-inlay {
    inset-block-start: 50%;
    inline-size: 0.5rem;
    block-size: 0.5rem;
    border-radius: 50%;
    background-color: var(--muted);
    box-shadow: inset 0 0 0 1px var(--border);
    &[data-double] {
      box-shadow:
        inset 0 0 0 1px var(--border),
        0 calc(var(--fretboard-neck-gap) * -1) 0 var(--muted),
        0 var(--fretboard-neck-gap) 0 var(--muted);
    }
  }
  .fretboard-neck-fret-number {
    inset-block-start: calc(100% + 0.625rem);
    color: var(--muted-foreground);
    font-family: var(--font-mono);
    font-size: 0.625rem;
  }
  .fretboard-neck-dot {
    inset-block-start: calc((var(--fretboard-neck-s, 1) - 0.5) * var(--fretboard-neck-gap));
    display: grid;
    place-items: center;
    inline-size: 1.125rem;
    block-size: 1.125rem;
    border-radius: 50%;
    font-family: var(--font-mono);
    font-size: 0.625rem;
    font-weight: 600;
    line-height: 1;
    &[data-nut] {
      inset-inline-start: calc(var(--fretboard-neck-nut) / 2);
    }
    /* the shape */
    &[data-role="chord"] {
      background-color: color-mix(in oklab, var(--fretboard-neck-color, var(--muted-foreground)) 30%, var(--card));
    }
    &[data-role="muted"] {
      color: var(--muted-foreground);
    }
    /* what comes next */
    &[data-role="next"] {
      border: 2px dashed var(--fretboard-neck-color, var(--primary));
      background-color: var(--card);
    }
    /* what sounds now */
    &[data-role="active"] {
      background-color: var(--fretboard-neck-color, var(--primary));
      color: var(--primary-foreground);
      box-shadow: 0 0 0 3px color-mix(in oklab, var(--fretboard-neck-color, var(--primary)) 25%, transparent);
    }
  }
  /* upright: the nut on top, the strings from the highest-numbered on the left (from the right for the right hand) */
  .fretboard-neck[data-orientation="vertical"] {
    & .fretboard-neck-board {
      min-inline-size: 0;
      inline-size: calc(var(--fretboard-neck-strings) * var(--fretboard-neck-gap));
      block-size: calc(var(--fretboard-neck-nut) + var(--fretboard-neck-frets) * var(--fretboard-neck-fret-size));
      margin-block: 1.25rem 0.5rem;
      margin-inline-start: 1.75rem;
    }
    & .fretboard-neck-string,
    & .fretboard-neck-string-name,
    & .fretboard-neck-dot {
      inset-inline-start: calc(
        (var(--fretboard-neck-strings) - var(--fretboard-neck-s, 1) + 0.5) * var(--fretboard-neck-gap)
      );
    }
    & .fretboard-neck-string {
      inset-block: calc(var(--fretboard-neck-nut) - 2px) 0;
      inline-size: 1.5px;
      block-size: auto;
      translate: var(--fretboard-neck-tx) 0;
    }
    & .fretboard-neck-string-name {
      inset-block-start: -0.75rem;
      inset-inline-end: auto;
      translate: var(--fretboard-neck-tx) -50%;
    }
    & .fretboard-neck-wires {
      inset-block: var(--fretboard-neck-nut) 0;
      inset-inline: calc(var(--fretboard-neck-gap) / 2 - 1px);
      border-inline-start: 0;
      border-block-start: 4px solid var(--foreground);
      background-image: linear-gradient(to top, var(--border) 2px, transparent 2px);
      background-size: 100% calc(100% / var(--fretboard-neck-frets));
    }
    & .fretboard-neck-inlay,
    & .fretboard-neck-fret-number,
    & .fretboard-neck-dot {
      inset-block-start: calc(
        var(--fretboard-neck-nut) + (100% - var(--fretboard-neck-nut)) * (var(--fretboard-neck-f, 1) - 0.5) /
          var(--fretboard-neck-frets)
      );
    }
    & .fretboard-neck-inlay {
      inset-inline-start: 50%;
      &[data-double] {
        box-shadow:
          inset 0 0 0 1px var(--border),
          calc(var(--fretboard-neck-gap) * -1) 0 0 var(--muted),
          var(--fretboard-neck-gap) 0 0 var(--muted);
      }
    }
    & .fretboard-neck-fret-number {
      inset-inline-start: -1rem;
    }
    & .fretboard-neck-dot[data-nut] {
      inset-block-start: calc(var(--fretboard-neck-nut) / 2);
    }
    &[data-capo] .fretboard-neck-wires {
      border-block-start-width: 2px;
    }
    & .fretboard-neck-capo {
      inset-block: calc(
          var(--fretboard-neck-nut) + (100% - var(--fretboard-neck-nut)) * var(--fretboard-neck-f, 1) /
            var(--fretboard-neck-frets)
        )
        auto;
      inset-inline: calc(var(--fretboard-neck-gap) / 2 - 4px);
      inline-size: auto;
      block-size: 5px;
      translate: 0 -50%;
    }
    & .fretboard-neck-barre {
      inset-block-start: calc(
        var(--fretboard-neck-nut) + (100% - var(--fretboard-neck-nut)) * (var(--fretboard-neck-f, 1) - 0.5) /
          var(--fretboard-neck-frets)
      );
      inset-inline-start: calc(
        (var(--fretboard-neck-strings) - var(--fretboard-neck-s2, 1) + 0.5) * var(--fretboard-neck-gap) - 0.5rem
      );
      inline-size: calc((var(--fretboard-neck-s2, 1) - var(--fretboard-neck-s1, 1)) * var(--fretboard-neck-gap) + 1rem);
      block-size: 0;
      border-inline-start: 0;
      border-block-start: 3px dashed var(--muted-foreground);
      translate: 0 -50%;
    }
  }
  /* string colours (data-colors="strings"): string n takes chart colour n - its line, its name, its dots - and from
     the 6th on they start over; text in the string's colour mixed toward the text colour, legible in either mode */
  .fretboard-neck[data-colors="strings"] {
    & :is([data-string="1"], [data-string="6"], [data-string="11"]) {
      --fretboard-neck-color: var(--chart-1);
    }
    & :is([data-string="2"], [data-string="7"], [data-string="12"]) {
      --fretboard-neck-color: var(--chart-2);
    }
    & :is([data-string="3"], [data-string="8"]) {
      --fretboard-neck-color: var(--chart-3);
    }
    & :is([data-string="4"], [data-string="9"]) {
      --fretboard-neck-color: var(--chart-4);
    }
    & :is([data-string="5"], [data-string="10"]) {
      --fretboard-neck-color: var(--chart-5);
    }
    & .fretboard-neck-string {
      block-size: 2.5px;
    }
    &[data-orientation="vertical"] .fretboard-neck-string {
      inline-size: 2.5px;
      block-size: auto;
    }
    & :is(.fretboard-neck-string-name, .fretboard-neck-dot[data-role="muted"]) {
      color: color-mix(in oklab, var(--fretboard-neck-color, var(--foreground)) 55%, var(--foreground));
      font-weight: 700;
    }
    & .fretboard-neck-dot[data-role="active"] {
      background-color: color-mix(in oklab, var(--fretboard-neck-color, var(--primary)) 40%, var(--card));
      box-shadow:
        0 0 0 3px var(--fretboard-neck-color, var(--primary)),
        0 0 0 6px color-mix(in oklab, var(--fretboard-neck-color, var(--primary)) 25%, transparent);
      color: color-mix(in oklab, var(--fretboard-neck-color, var(--foreground)) 55%, var(--foreground));
    }
  }
  @media (prefers-contrast: more) {
    .fretboard-neck-dot[data-role="chord"] {
      background-color: var(--muted-foreground);
      color: var(--card);
    }
  }
  @media (forced-colors: active) {
    .fretboard-neck-dot[data-role="active"] {
      background-color: Highlight;
      color: HighlightText;
      forced-color-adjust: none;
    }
    .fretboard-neck-string,
    .fretboard-neck-capo {
      background-color: CanvasText;
      forced-color-adjust: none;
    }
  }
}

§JavaScript view file

The shape's and the notes' parsers, the barre a shape can hold, the board drawn from the markup with each position's strongest role, the description, the State API and df$.shadcn.fretboardNeck.

// -- Fretboard Neck ----------------------------------------------
// The neck of a fretted instrument from its markup: the strings of its tuning, the nut on the fretting hand's side (or
// a capo's bold bar with the frets behind it), the inlays and fret numbers - and the fingers on it: a chord's shape
// (faint; its barre a dashed line; × on a muted string), the notes sounding (filled, with their fret) and the notes
// coming next (outlined); nothing on an open string. Across, the 1st string on top as a tab draws it, or upright as a
// chord chart draws it; the right hand mirrors either. Bound to a Fretboard (data-fretboard-for) it follows the
// playhead: the Fretboard sets its tuning, capo and frets and, as it plays, its shape and notes.
// 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 fretboardNeckStates = ['default'];
/** The config the state takes. */
export interface FretboardNeckConfig {
  /** a chord's shape, drawn faint: a fret per string from the string the tuning names first to the 1st ("0003", "x32010", "x 10 12 12 12 10"); "" for none */
  shape?: string;
  /** the fret the shape's barre lies on, null for none */
  barre?: number | null;
  /** the notes sounding, drawn filled with their fret: "string/fret" pairs ("3/2 2/3"; string 1 is the highest), or a list of { string, fret } */
  notes?: string | FretboardNeckNote[];
  /** the notes coming next, drawn outlined - as notes */
  next?: string | FretboardNeckNote[];
  /** the capo's fret, 0 for none; the frets of the shape and the notes count from it */
  capo?: number;
}
/** A note on the neck. */
export interface FretboardNeckNote {
  /** the string, 1 = the highest */
  string: number;
  /** the fret, 0 = open (nothing is drawn on an open string) */
  fret: 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 FretboardNeckStateConfigs {
  /** The neck with the fingers the config puts on it. */
  default: FretboardNeckConfig;
}
type Fret = number | 'x';
type Barre = { fret: number; from: number; to: number };
type Note = { string: number; fret: number | 'x' };
const INLAYS = [3, 5, 7, 10, 12, 15];
const ROLES = ['muted', 'chord', 'next', 'active'];
let described = 0;
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,
  );
const numAttr = (el: Element, name: string): number | null => {
  const v = el.getAttribute(name);
  return v === null || v === '' || !Number.isFinite(Number(v)) ? null : Number(v);
};
/** 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;
}
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 };
}
/** notes from their text ("3/2 2/3") or a list - each on a string and a fret that exist */
function parseNotes(v: unknown): FretboardNeckNote[] {
  const list: unknown[] = Array.isArray(v)
    ? v
    : String(v ?? '')
        .trim()
        .split(/\s+/)
        .filter(Boolean);
  const out: FretboardNeckNote[] = [];
  for (const x of list) {
    const [s, f] =
      typeof x === 'string'
        ? x.split('/').map(Number)
        : [Number((x as FretboardNeckNote)?.string), Number((x as FretboardNeckNote)?.fret)];
    if (Number.isInteger(s) && s >= 1 && s <= 12 && Number.isInteger(f) && f >= 0 && f <= 24)
      out.push({ string: s, fret: f });
  }
  return out;
}
const notesText = (notes: FretboardNeckNote[]): string => notes.map((n) => `${n.string}/${n.fret}`).join(' ');
/** the tuning's names, from the string it names first (the highest-numbered) to the 1st */
const tuningOf = (el: Element): string[] => (el.getAttribute('data-tuning') ?? '').trim().split(/\s+/).filter(Boolean);
const capoOf = (el: Element): number => clamp(Math.round(numAttr(el, 'data-capo') ?? 0), 0, 12);
/** the neck: its board (strings, nut or capo, wires, inlays, fret numbers) and the fingers on it */
function neckMarkup(el: Element): { board: string; text: string } {
  const shape = parseShape(el.getAttribute('data-shape'));
  const tuning = tuningOf(el);
  const n = tuning.length || shape?.length || Math.max(1, numAttr(el, 'data-strings') ?? 4);
  const capo = capoOf(el);
  const notes = parseNotes(el.getAttribute('data-notes'));
  const next = parseNotes(el.getAttribute('data-next'));
  const highest = Math.max(
    3,
    ...(shape ?? []).map((f) => (typeof f === 'number' ? f : 0)),
    ...notes.map((x) => x.fret),
    ...next.map((x) => x.fret),
  );
  const frets = clamp(Math.round(numAttr(el, 'data-frets') ?? capo + highest + 2), 3, 24);
  const strings = Array.from({ length: n }, (_, k) => {
    const s = n - k;
    const name = tuning[k] ?? '';
    return `${name ? `<span class="fretboard-neck-string-name" data-string="${s}" style="--fretboard-neck-s: ${s}">${esc(name)}</span>` : ''}<span class="fretboard-neck-string" data-string="${s}" style="--fretboard-neck-s: ${s}"></span>`;
  }).join('');
  const inlays = INLAYS.filter((f) => f <= frets)
    .map(
      (f) =>
        `<span class="fretboard-neck-inlay" style="--fretboard-neck-f: ${f}"${f === 12 ? ' data-double' : ''}></span>`,
    )
    .join('');
  const numbers = INLAYS.filter((f) => f <= frets)
    .map((f) => `<span class="fretboard-neck-fret-number" style="--fretboard-neck-f: ${f}">${f}</span>`)
    .join('');
  const capoBar = capo ? `<span class="fretboard-neck-capo" style="--fretboard-neck-f: ${capo}"></span>` : '';
  // the fingers: each position takes its strongest role
  const roles = new Map<string, string>();
  const put = (x: Note, role: string) => {
    if (x.fret === 0 || x.string < 1 || x.string > n) return;
    const k = `${x.string}/${x.fret}`;
    if (ROLES.indexOf(role) > ROLES.indexOf(roles.get(k) ?? '')) roles.set(k, role);
  };
  const barre = shape ? barreOf(shape, numAttr(el, 'data-barre') ?? 0) : null;
  (shape ?? []).forEach((fret, k) => {
    if (barre && fret === barre.fret && k >= barre.from && k <= barre.to) return;
    put({ string: n - k, fret }, fret === 'x' ? 'muted' : 'chord');
  });
  next.forEach((x) => put(x, 'next'));
  notes.forEach((x) => put(x, 'active'));
  const bar = barre
    ? `<span class="fretboard-neck-barre" style="--fretboard-neck-f: ${capo + barre.fret}; --fretboard-neck-s1: ${n - barre.to}; --fretboard-neck-s2: ${n - barre.from}"></span>`
    : '';
  const dots = [...roles]
    .map(([k, role]) => {
      const [string, fret] = k.split('/');
      // a muted string: × at the nut, or just behind the capo
      const muted = fret === 'x';
      return `<span class="fretboard-neck-dot" data-role="${role}" data-string="${string}"${muted && !capo ? ' data-nut' : ''} style="--fretboard-neck-s: ${string}; --fretboard-neck-f: ${muted ? capo : capo + Number(fret)}">${role === 'active' || role === 'muted' ? (muted ? '×' : fret) : ''}</span>`;
    })
    .join('');
  const say = (list: FretboardNeckNote[]) =>
    list.map((x) => `string ${x.string} ${x.fret ? `fret ${x.fret}` : 'open'}`).join(', ');
  const text = [
    tuning.length ? `${tuning.join(' ')} neck` : `${n} strings`,
    capo ? `capo ${capo}` : '',
    shape ? `shape ${shape.join(' ')}${barre ? `, barre at fret ${barre.fret}` : ''}` : '',
    notes.length ? `sounding ${say(notes)}` : '',
    next.length ? `next ${say(next)}` : '',
  ]
    .filter(Boolean)
    .join('; ');
  const board = `<div class="fretboard-neck-board" aria-hidden="true" style="--fretboard-neck-strings: ${n}; --fretboard-neck-frets: ${frets}"><div class="fretboard-neck-wires"></div>${strings}${inlays}${numbers}${capoBar}${bar}${dots}</div>`;
  return { board, text };
}
/** Draw the neck again from its markup. */
function draw(el: HTMLElement): void {
  const m = neckMarkup(el);
  const rt = el._fretboardNeck as { board: string; desc: HTMLElement };
  if (rt.board !== m.board) {
    rt.board = m.board;
    const board = dfDollar(m.board).get(0) as HTMLElement;
    const old = dfDollar(el).children('.fretboard-neck-board');
    if (old.length) old.replaceWith(board);
    else dfDollar(el).prepend(board);
  }
  if (dfDollar(rt.desc).text() !== m.text) dfDollar(rt.desc).text(m.text);
}
/** the markup of the state: the shape, barre, notes, next notes and capo as data attributes */
function applyMarkup(el: Element, _stateName: string, config: FretboardNeckConfig): void {
  const write = (name: string, v: string | null) => {
    if (el.getAttribute(name) !== v) dfDollar(el).attr(name, v);
  };
  // the authored text stays while it means the same shape or notes ("x 5 7 7 7 5" is x57775)
  const same = (name: string, text: string | null, norm: (v: string | null) => string | null) =>
    norm(el.getAttribute(name)) === text;
  const shapeNorm = (v: string | null) => {
    const s = parseShape(v);
    return s ? shapeText(s) : null;
  };
  const notesNorm = (v: string | null) => notesText(parseNotes(v)) || null;
  if (config.shape !== undefined) {
    const text = shapeNorm(typeof config.shape === 'string' ? config.shape : null);
    if (!same('data-shape', text, shapeNorm)) write('data-shape', text);
  }
  if (config.barre !== undefined)
    write(
      'data-barre',
      config.barre === null || !(Number(config.barre) > 0) ? null : String(Math.round(Number(config.barre))),
    );
  if (config.notes !== undefined) {
    const text = notesText(parseNotes(config.notes)) || null;
    if (!same('data-notes', text, notesNorm)) write('data-notes', text);
  }
  if (config.next !== undefined) {
    const text = notesText(parseNotes(config.next)) || null;
    if (!same('data-next', text, notesNorm)) write('data-next', text);
  }
  if (config.capo !== undefined) {
    const c = clamp(Math.round(Number(config.capo) || 0), 0, 12);
    write('data-capo', c ? String(c) : null);
  }
}
/** what the neck shows, read from its markup */
const configOf = (el: Element) => ({
  shape: el.getAttribute('data-shape') ?? '',
  barre: numAttr(el, 'data-barre'),
  notes: el.getAttribute('data-notes') ?? '',
  next: el.getAttribute('data-next') ?? '',
  capo: capoOf(el),
});
/** UI side of setState: the state's markup, the neck drawn again. */
function triggerStateChange(el: HTMLElement, stateName: string, incoming: FretboardNeckConfig): undefined {
  applyMarkup(el, stateName, incoming);
  if (el._fretboardNeck) draw(el);
  return undefined;
}
/** Registry-level API; pass the .fretboard-neck element explicitly. Unknown names throw. */
export const fretboardNeckApi = componentState({
  component: 'fretboard-neck',
  states: fretboardNeckStates,
  // the config is the fingers on the neck: a setState names what changes, the rest stays
  mergeConfig: true,
  apply: (el, state, _previous, incoming) => triggerStateChange(el, state.name, incoming as FretboardNeckConfig),
  // a bound Fretboard and the markup change it without setState
  read: (el, state) => {
    const now = configOf(el);
    const c = state.config as FretboardNeckConfig;
    if (
      c.shape === now.shape &&
      c.barre === now.barre &&
      c.notes === now.notes &&
      c.next === now.next &&
      c.capo === now.capo
    )
      return state;
    return { name: 'default', config: now as Record<string, unknown> };
  },
  markup: (el, state) => applyMarkup(el, state.name, state.config as FretboardNeckConfig),
});
df$.fretboardNeckApi = fretboardNeckApi;
df$.fretboardNeckStates = fretboardNeckStates;
function init() {
  dfDollar('.fretboard-neck:not([data-init])')
    .toArray()
    .forEach((el: HTMLElement) => {
      el.dataset.init = '';
      const desc = dfDollar(
        `<span class="fretboard-neck-desc" id="fretboard-neck-desc-${++described}" hidden></span>`,
      ).get(0) as HTMLElement;
      dfDollar(el).append(desc);
      if (!el.hasAttribute('role')) dfDollar(el).attr('role', 'img');
      const by = (el.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean);
      dfDollar(el).attr('aria-describedby', [...by, desc.id].join(' '));
      el._fretboardNeck = { board: '', desc };
      draw(el);
      // el.store + el.api (AGENTS.md "State through stores"): $('#neck').api.setState('default', { notes: '3/2' })
      bindComponent(el, fretboardNeckApi, { name: 'default', config: configOf(el) });
      // the markup changes the neck: drawn again
      new MutationObserver(() => draw(el)).observe(el, {
        attributes: true,
        attributeFilter: [
          'data-tuning',
          'data-strings',
          'data-frets',
          'data-shape',
          'data-barre',
          'data-notes',
          'data-next',
          'data-capo',
        ],
      });
    });
}
/** the neck of a target: the .fretboard-neck element or a selector of it */
const resolve = (target: string | HTMLElement): HTMLElement | undefined => {
  const el = typeof target === 'string' ? dfDollar(target).get(0) : target;
  const neck = el?.closest?.<HTMLElement>('.fretboard-neck');
  return neck?._fretboardNeck ? neck : undefined;
};
df$.fretboardNeck = {
  /**
   * Put fingers on the neck - the notes sounding, and optionally a shape and the notes next (setState; what it does
   * not name stays).
   * @param target - the .fretboard-neck element or a selector of it
   * @param notes - the notes sounding: "3/2 2/3" or a list of { string, fret }; [] or "" for none
   * @param config - the shape, barre, next notes or capo to show with them
   * @returns the notes now, as { string, fret }; [] for an unknown element
   */
  play: (
    target: string | HTMLElement,
    notes: string | FretboardNeckNote[],
    config: FretboardNeckConfig = {},
  ): FretboardNeckNote[] => {
    const el = resolve(target);
    if (!el) return [];
    el.api?.setState('default', { ...config, notes });
    return parseNotes(el.getAttribute('data-notes'));
  },
};
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

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