@tungstenstudio/dartboard-input
v1.1.0
Published
Interactive SVG dartboard input component built with D3
Readme
@tungstenstudio/dartboard-input
Interactive SVG dartboard input component built with D3. Click, tap, or keyboard-navigate beds to fire throw events.
Install
npm install @tungstenstudio/dartboard-input d3-selection d3-shape
# or
pnpm add @tungstenstudio/dartboard-input d3-selection d3-shape
# or
yarn add @tungstenstudio/dartboard-input d3-selection d3-shape
# or
bun add @tungstenstudio/dartboard-input d3-selection d3-shapeQuick Start
import { Dartboard } from '@tungstenstudio/dartboard-input';
import '@tungstenstudio/dartboard-input/style.css';
const board = new Dartboard('#dartboard');
board.render();
document.querySelector('#dartboard').addEventListener('throw', (e) => {
console.log(e.detail); // { bed: 'T20', segment: 20, ring: 'triple', score: 60 }
});React Usage
import { useRef, useEffect } from 'react';
import { Dartboard } from '@tungstenstudio/dartboard-input';
import '@tungstenstudio/dartboard-input/style.css';
function DartboardInput({ onThrow }) {
const ref = useRef(null);
useEffect(() => {
const board = new Dartboard(ref.current, { size: 400 });
board.render();
const handler = (e) => onThrow?.(e.detail);
ref.current.addEventListener('throw', handler);
return () => board.destroy();
}, []);
return <div ref={ref} />;
}API
new Dartboard(container, options?, segments?, rings?)
Creates a dartboard instance.
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| container | string \| HTMLElement | — | CSS selector or DOM element |
| options | Partial<DartboardOptions> | DEFAULT_OPTIONS | Size, padding, and ring proportions |
| segments | Segment[] | DEFAULT_SEGMENTS | Board segment layout (standard 1–20) |
| rings | Rings | DEFAULT_RINGS | Ring definitions (names, abbreviations, multipliers) |
When size is null (the default), the board auto-sizes to fit its container using Math.min(offsetHeight, offsetWidth). The padding option (default 0) adds inner padding so the board edges aren't clipped by the viewBox boundary — the SVG viewBox stays at the full size while the board radius shrinks by the padding amount.
board.render(): this
Renders the dartboard SVG. Chainable.
board.destroy(): void
Removes the board from the DOM and cleans up references.
board.throwAt(target): void
Programmatically triggers a throw event.
board.throwAt('T20'); // bed string
board.throwAt({ segment: 20, ring: 'TRIPLE' }); // explicit BedTargetboard.highlight(targets, options?): void
Adds a CSS class to targeted beds. All targeting methods accept any mix of bed strings, numbers, and BedTarget objects.
// Bed strings — target specific beds
board.highlight(['T20', 'D16', 'DB25']);
// Segment number — target all scoring beds on that segment
board.highlight([20]); // double, triple, inner single, outer single
board.highlight(['20']); // same thing as a string
// Miss ring
board.highlight(['miss']); // highlight entire miss ring
board.highlight(['M20']); // highlight a specific miss bed
// BedTarget object — for precision targeting
board.highlight([{ segment: 20, ring: 'INNER_SINGLE' }]);
// BedTarget without ring — same as segment number
board.highlight([{ segment: 20 }]);
// Custom class name
board.highlight(['D20'], { className: 'my-highlight' });board.unhighlight(targets, options?): void
Removes a CSS class from specific beds (counterpart to highlight).
board.unhighlight(['T20']);
board.unhighlight([20]); // unhighlight all beds on segment 20
board.unhighlight(['miss']); // unhighlight entire miss ringboard.reset(className?): void
Removes highlight class from all beds.
board.disable(): this
Disables the board — suppresses all throw and hover events, dims the board visually. Chainable.
board.enable(): this
Re-enables the board after disable(). Chainable.
board.disabled: boolean
Read-only property indicating whether the board is currently disabled.
board.segments: Segment[]
Read-only array of segments the board was constructed with.
board.rings: Rings
Read-only ring definitions the board was constructed with.
Bed Strings
Beds are identified by an abbreviation prefix and a segment number:
| Input | Targets | Example |
|-------|---------|---------|
| T + number | Triple | 'T20' |
| D + number | Double | 'D16' |
| S + number | Both singles (inner + outer) | 'S20' |
| DB + number | Double Bull (Bullseye) | 'DB25' |
| B + number | Single Bull (Bull) | 'B25' |
| M + number | Miss | 'M20' |
| number only | All scoring beds on that segment | '20' or 20 |
| 'miss' | Entire miss ring (all 20 segments) | 'miss' |
When a number is used alone, all scoring beds on that segment are targeted (double, triple, inner single, outer single, single bull, double bull) — the miss ring is excluded. To target a specific single, use the explicit BedTarget form: { segment: 20, ring: 'INNER_SINGLE' }.
Events
throw
Dispatched on the container element when a bed is clicked, tapped, or activated via keyboard.
interface ThrowDetail {
bed: string; // e.g. 'T20', 'D16', 'B25', 'DB25', 'M20'
segment: number; // e.g. 20, 16, 25
ring: string; // e.g. 'triple', 'double', 'singleBull', 'doubleBull', 'miss'
score: number; // e.g. 60, 32, 25, 50, 0
}hover
Dispatched when a bed is entered or left.
interface HoverDetail {
bed: string;
segment: number;
ring: string;
score: number;
hovering: boolean;
}Segment States
Three built-in state classes are included for marking beds as good, bad, or neutral — useful for coaching sessions, practice games, or heatmaps:
// Mark good targets (green)
board.highlight(['T20'], { className: 'is-good' });
// Mark bad targets (red)
board.highlight(['T1'], { className: 'is-bad' });
// Mark neutral targets (blue)
board.highlight(['D5'], { className: 'is-neutral' });
// Remove a specific state
board.unhighlight(['T1'], { className: 'is-bad' });
// Clear all of one state
board.reset('is-good');The colors are themeable via CSS custom properties:
.c-Dartboard {
--dartboard-good: #228b22;
--dartboard-bad: #e32636;
--dartboard-neutral: #4a6fa5;
}You can also define your own state classes. Use this specificity pattern to override the default bed fills:
.c-Dartboard .c-Dartboard-bed.is-warning.isDark,
.c-Dartboard .c-Dartboard-bed.is-warning.isLight {
fill: orange;
}Bed Labels
Overlay a short, centered text label on every scoring bed, and update or clear it at runtime — without re-rendering the board. The label text is opaque: whatever string your labeler returns is rendered verbatim and given no meaning by the library, so it works equally for scores, coaching cues, glyphs, hit counts, or anything else.
board.labelBeds(labeler, options?): this
Draws (or replaces) a label on every scoring bed. The labeler is called once per bed with a BedLabelContext — the full bed model (segment, color, ring) plus a convenience score (segment × ring.multiplier; single bull = 25, double bull = 50). Return a string to render, or null / undefined / '' to skip that bed. Each call fully replaces the previous labels (no accumulation), so just call it again whenever your state changes. Chainable.
// "What's left" — overlay the score remaining after each bed, for a player on 70
board.labelBeds((bed) => String(70 - bed.score));
// S20 → "50", T20 → "10", D20 → "30", single bull → "45", double bull → "20"Because the returned string is opaque, the same API labels beds with anything:
// Mark good/bad zones — arbitrary text, no scoring involved
board.labelBeds((bed) => (bed.ring.multiplier === 3 ? 'GOOD' : 'BAD'));
// A glyph on one bed, nothing elsewhere
board.labelBeds((bed) => (bed.ring.abbr === 'T' && bed.segment === 20 ? '✓' : ''));board.clearBedLabels(): this
Removes all bed labels. (destroy() also removes them; reset() does not — it only clears highlight classes.) Chainable.
Notes
- Coverage: all 82 scoring beds — inner/outer single, double, triple, and both bull rings. The MISS ring is never labeled (it already renders the 1–20 numbers and always scores 0).
- Placement: labels are centered on each bed via the board's own geometry and scale with the board
size. The double bull label sits at the center; the single bull label sits at the bottom of its ring, so the two never overlap. - Layering: labels render above the beds but below dart markers (
addDart), and never intercept pointer/keyboard events, highlights, orthrow/hoverevents.
Styling
Each label is a <text class="c-Dartboard-label"> (plus your optional options.className). Color is automatic: labels are light by default with a thin dark halo so they stay legible on the mid-tone red/green scoring beds, while the light (beige) single beds use dark text. All three are themeable:
.c-Dartboard {
--dartboard-label-on-dark: #fff; /* text on black / red / green beds */
--dartboard-label-on-light: #111; /* text on the beige single beds */
--dartboard-label-halo: rgba(0, 0, 0, 0.6); /* outline drawn behind every label */
}The scoring beds (double, triple, both bulls) are red/green even on their "light" segments, so their labels carry an extra c-Dartboard-label--scoring class that keeps the text light there.
The default font size scales with the board. Override it by targeting the text:
.c-Dartboard-label {
font-size: 18px;
font-weight: 700;
}Pass options.className to style one overlay independently:
board.labelBeds((bed) => String(70 - bed.score), { className: 'remaining' });Mobile / Touch-Friendly Sizing
The default ring proportions can be too narrow for comfortable tapping on small screens. The exported MOBILE_OPTIONS preset widens the scoring rings (double, triple, bull) for larger touch targets:
import { Dartboard, MOBILE_OPTIONS } from '@tungstenstudio/dartboard-input';
const isMobile = window.matchMedia('(max-width: 600px)').matches;
const board = new Dartboard('#dartboard', isMobile ? MOBILE_OPTIONS : {});
board.render();You can also build your own proportions — the *Percent options control each ring's share of the radius (they should sum to 100):
new Dartboard('#dartboard', {
missPercent: 7,
doublePercent: 14,
outerSinglePercent: 21,
triplePercent: 14,
innerSinglePercent: 26,
singleBullPercent: 9,
doubleBullPercent: 9,
});Theming
Override CSS custom properties on the .c-Dartboard element:
.c-Dartboard {
--dartboard-dark: #000;
--dartboard-light: #f5f5dc;
--dartboard-scoring-dark: #e32636;
--dartboard-scoring-light: #228b22;
--dartboard-miss: #000;
--dartboard-miss-label: #fff;
--dartboard-stroke: silver;
--dartboard-stroke-width: 2px;
--dartboard-highlight-color: #ffd700;
--dartboard-dark-hover: #444;
--dartboard-good: #228b22;
--dartboard-bad: #e32636;
--dartboard-neutral: #4a6fa5;
--dartboard-label-on-dark: #fff;
--dartboard-label-on-light: #111;
--dartboard-label-halo: rgba(0, 0, 0, 0.6);
}CSS Class Structure
Each bed element in the SVG has several classes you can use for fine-grained styling:
| Class | Description |
|-------|-------------|
| .c-Dartboard | Wrapper div around the SVG |
| .c-Dartboard-bed | Every clickable bed element |
| .c-Dartboard-bed--{n} | Bed for segment number n (e.g. .c-Dartboard-bed--20) |
| .c-Dartboard-{ring} | Ring group: double, triple, innerSingle, outerSingle, singleBull, doubleBull, miss |
| .isDark / .isLight | Alternating segment color class |
| .is-hovered | Applied while a bed is pointer-hovered |
| .is-highlighted | Default highlight class (includes a pulsing animation) |
| .is-disabled | Applied to the wrapper when the board is disabled |
| .c-Dartboard-missLabel | Segment number labels in the miss ring |
| .c-Dartboard-labels | Group wrapping all bed labels (pointer-events: none) |
| .c-Dartboard-label | A single bed label from labelBeds; also carries .isDark/.isLight |
| .c-Dartboard-label--scoring | Label on a red/green scoring bed (double, triple, bull); keeps light text |
To override fills for a specific ring, combine the ring group with bed classes:
/* Make all triple beds gold */
.c-Dartboard .c-Dartboard-triple .c-Dartboard-bed.isDark,
.c-Dartboard .c-Dartboard-triple .c-Dartboard-bed.isLight {
fill: gold;
}
/* Style a single bed */
.c-Dartboard .c-Dartboard-triple .c-Dartboard-bed--20 {
fill: hotpink;
}Keyboard & Accessibility
All beds are focusable (tabindex="0") with role="button" and descriptive aria-label attributes (e.g. "T20, scores 60"). The SVG has role="group" and aria-label="Dartboard".
- Enter or Space on a focused bed dispatches a
throwevent - Tab / Shift+Tab navigates between beds
- Focus is indicated with an outline using
--dartboard-highlight-color
Terminology
This library uses standard darts terminology:
- Segment — one of the 20 numbered wedges on the board (1–20)
- Bed — a specific scoring zone: the intersection of a segment and a ring (e.g., "triple 20")
- Ring — a concentric band: double, triple, inner single, outer single, single bull, double bull, miss
Types
All types are exported for TypeScript consumers:
Dartboard— main classDartboardOptions— constructor optionsThrowDetail— throw event payloadHoverDetail— hover event payloadBedTarget— explicit target ({ segment, ring? }) — omitringto target all scoring bedsBedInput— union ofBedTarget | string | numberaccepted by all targeting methodsHighlightOptions— options forhighlightBedLabelContext— the labeler argument forlabelBeds(Bed & { score: number })BedLabelOptions— options forlabelBedsRing,Rings,Segment,Bed— board model types
Constants
DEFAULT_OPTIONS— default ring proportions (see below)MOBILE_OPTIONS— wider scoring rings for touch targetsDEFAULT_RINGS— standard ring definitions (name, abbreviation, multiplier)DEFAULT_SEGMENTS— standard 1–20 segment layout with positions and colors
Default values (DEFAULT_OPTIONS):
| Option | Default | Mobile |
|--------|---------|--------|
| padding | 2 | — |
| missPercent | 10 | 7 |
| doublePercent | 10 | 14 |
| outerSinglePercent | 25 | 21 |
| triplePercent | 10 | 14 |
| innerSinglePercent | 30 | 26 |
| singleBullPercent | 8 | 9 |
| doubleBullPercent | 7 | 9 |
Utilities
Exported helper functions:
parseBed(bed, rings, segments?)— parse a bed string intoBedTarget[]buildThrowDetail(bed)— convert aBedinto aThrowDetailobjectaddRingToSegments(ring, segments)— combine aRingwithSegment[]to produceBed[]asPercent(n)— convert an integer percentage to a decimal (e.g.10→0.1)
License
ISC
