Interactions
Static by default. Opt in with inspect, select, zoom, legendFocus, legendFilter. With more than one draw tool, an accessible tool rail keeps gestures from competing.
Without a controller, state is private to one chart and callbacks report changes. Pass createPlotInteraction() when plots, controls, or tables share semantic state (required, stable semantic scope via interactionScope).
Examples: inspect, interval/zoom, linked views, legend focus, legend filter, facet intervals. Contracts: interaction reference.
Inspection
inspect={true} enables the default HTML tooltip, semantic crosshair, keyboard traversal, and click-or-Enter pinning. Configure it when the chart has a natural comparison axis:
<GGPlot
{data}
aes={{ x: "date", y: "value", color: "series" }}
key="id"
inspect={{ mode: "x", pin: true, maxDistance: 24 }}
oninspect={(event) => console.log(event)}
>
<GeomLine />
<GeomPoint />
</GGPlot>
The modes are auto, exact, x, y, and xy. auto resolves to a concrete mode before an event is emitted. x and y return one representative per semantic series at the focused axis value; exact and xy return the focused datum. maxDistance is measured in CSS pixels: the dominant axis for x or y, Euclidean distance for xy, and geometry containment plus tolerance for exact. Rect marks (geom_col / geom_bar) never draw a point ring; default hover is tooltip-only. Pass muteSiblings: true to mute non-focused bars via the interaction mask.
For custom HTML, pass a Svelte 5 snippet. Informational content is the default; choose contentMode: "interactive" only when the pinned tooltip contains controls that need focus.
{#snippet details(inspection)}
<strong>{inspection.focus.row?.name}</strong>
<span>{inspection.members.length} series at this value</span>
{/snippet}
<GGPlot inspect={{ mode: "x", content: details }} />
Point and interval selection
Point selection is durable identity, not a renderer index. Supply a unique, stable string, number, or symbol for every source row:
<GGPlot
key="id"
select={{ type: "point", multiple: true }}
onselect={(event) => {
if (event.mode === "point") selectedKeys = event.keys;
}}
/>
Use interval selection for brushing. The callback receives both the selected domain and normalized plot-pixel rectangle, plus semantic keys and a lineage count for aggregate marks.
<GGPlot
key="id"
select={{ type: "interval", mode: "xy", persistent: true }}
onselect={(event) => {
if (event.mode !== "point" && event.phase === "end") {
selectedDomain = event.domain;
}
}}
/>
Faceted intervals use stable field-and-value panel identities rather than panel indices. Choose a preset for the relationship between panels:
independent(default) replaces the interval in only the origin panel.unionkeeps independently drawn panel intervals and combines their keys.cross-panelprojects one semantic domain through every compatible panel.
cross-panel intersects the interval with each panel's domain when facet scales are free; a disjoint panel selects nothing instead of clamping to an unrelated edge. Panel identity survives row reordering and temporary absence. See the runnable facet example.
Shared controlled state
createPlotInteraction<Key>() owns selection, emphasis, and continuous zoom domains outside any chart. Give linked consumers the same controller and a required, stable semantic scope via interactionScope. A transition is published once by its origin; passive charts render the new snapshot without emitting the callback again. Controlled plots never infer channel names: add an x and/or y scope whenever controlled zoom uses that channel.
<script lang="ts">
import { createPlotInteraction } from "@ggsvelte/svelte";
const interaction = createPlotInteraction<string>();
const scope = { keys: "penguin-id", x: "flipper-mm", y: "mass-g" } as const;
const selected = $derived(interaction.selected(scope));
</script>
<GGPlot
{data}
key="id"
select={{ type: "point", multiple: true }}
{interaction}
interactionScope={scope}
/>
<GGPlot
{data}
key="id"
select={{ type: "point", multiple: true }}
{interaction}
interactionScope={scope}
/>
<button onclick={() => interaction.setSelection(["gentoo-1"], { scope })}>
Select Gentoo 1
</button>
Use setSelection, toggleSelection, and clearSelection for durable keys. setEmphasis is presentation-only: linked charts update their highlight overlay without retraining scales or rerunning the render pipeline. Matching x and y scope names share numeric zoom domains. When application data is replaced, call reconcileKeys(validKeys, { scope }) explicitly; a chart never guesses whether a temporary subset should erase another view's selection.
Durable facet intervals use their own optional interactionScope.intervals namespace (falling back to keys). Read them with intervals(scope), write one with setInterval, clear one panel with clearInterval, or clear the scope with clearIntervals. Interval state is semantic data-space state, not pixels or renderer indices.
Legend focus
legendFocus={true} adds real HTML controls over discrete color and fill legends. Hover and DOM focus preview one chart without mutating shared state. Click, touch, Enter, or Space commits the matching stable row keys; the active entry or Escape clears them. Arrow keys traverse entries in rendered legend order, with Home and End moving to the boundaries.
legendFocus={{ preview: false }} keeps committed activation but disables transient previews. Continuous ramps remain static. A stable key is required: encoded legend values are reported as values, never used as controller keys. Focused and muted marks share one semantic mask across SVG and canvas, and the mask does not retrain scales, recompute statistics, change layout, or reassign colors. Author discrete legend appearance with GuideLegend; see the full guides reference and the runnable three-view example.
Legend filtering
legendFocus is presentation emphasis only — it does not change data. legendFilter={true} adds Show-group checkboxes on discrete color/fill legends and filters rows before facets, stats, scales, layout, and render. Hidden groups stay in the legend catalog and keep the same categorical color when shown again.
Use legendFilter={{ mode: "exclude", multiple: true }} for the default independent checkboxes. mode: "include" stores the shown values instead; multiple: false makes a toggle isolate one group. onlegendfilter reports the raw typed values and field in a LegendFilterClause. Reset legend filters restores the data pipeline; Clear legend focus only removes presentation emphasis. See the stable-color example.
Brush zoom
zoom={true} enables two-dimensional brush zoom. Set zoom={{ mode: "x" }} or zoom={{ mode: "y" }} for a single axis. The tool rail separates Zoom area from Select area when both are enabled. A completed zoom emits explicit domains; Reset zoom or double-click emits a clear event. Faceted interval selection is supported, but faceted brush zoom remains disabled with INTERACTION_INTERVAL_FACET_UNSUPPORTED; use a linked detail view when each facet needs a zoomed inspection surface.
<GGPlot
zoom={{ mode: "xy" }}
onzoom={(event) => console.log(event.domains)}
/>
Precise bounds without dragging
After an interval selection or zoom is committed, the tool rail exposes Edit x or y bounds alongside its drag controls. The inline HTML form stages edits: typing does not rerun the chart, Apply commits once, Cancel or Escape discards the draft, and validation focuses the first invalid field. This provides a keyboard and assistive-technology path to the same semantic result as brushing.
- Linear and reversed scales accept ascending data-space numbers. Reversal is presentation only, so do not enter screen order.
- Log scales accept positive ascending numbers.
- Time scales accept ISO 8601 dates or date-times with
Zor an explicit offset; events store Unix milliseconds. - Band scales use two native selects and include both endpoint categories.
Recovery actions are deliberately separate: Clear panel selection removes one facet interval, Clear all selections removes interval state, Reset zoom restores natural domains, and Reset legend filters restores excluded rows. None of these controls silently performs another reset.
Event reference
All events carry type, phase, and source (pointer, keyboard, touch, or programmatic). Use the focused callback for one capability or oninteraction for the discriminated union of every event.
oninspect(event: PlotInspection)
- A change is
{ type: "inspect", phase: "change", state, source, mode, panelId, focus, members }. stateistransientorpinned;membersis always non-empty andfocusis the member under direct inspection.xandychanges also carry the original logicalaxisValueand its formattedaxisLabel.- Dismissal is the small event
{ type: "inspect", phase: "clear", source }.
Each PlotDatum has key, source row when one exists, aggregate sourceKeys and lineageCount, layerIndex, panelId, mapped fields, and a plot-pixel anchor. Keyless or synthetic marks expose key: null; internal renderer indices never leak into callbacks.
onselect(event: PlotSelection)
- Point selection emits
{ type: "select", phase: "end" | "clear", mode: "point", keys, source }. - Interval selection emits
start,change,end, andclearphases withmode,panelId,domain,pixels,keys,lineageCount, andsource.
onzoom(event: ZoomEvent)
- Zoom completion is
{ type: "zoom", phase: "end", source, domains }. - Reset is
{ type: "zoom", phase: "clear", source, domains: null }.
onlegendfocus(event: LegendFocusEvent)
- Preview and commit emit
{ type: "legend-focus", phase: "change", state, source, scale, value, label, keys }. stateistransientorcommitted.valueis the raw encoded domain value whilekeysare distinct stable source-row identities.- Dismissal emits
{ type: "legend-focus", phase: "clear", source }.
onlegendfilter(event: LegendFilterEvent)
- A change emits
{ type: "legend-filter", phase: "change", source, clause }. clausenames the color or fill scale, source field, typed values, and include or exclude mode. Reset emitsphase: "clear"andclause: null.- Filtering is data-changing and intentionally separate from the presentation-only
onlegendfocusevent.
oninteraction(event: PlotInteractionEvent) receives the same objects. It does not wrap or duplicate them. A linked chart that consumes shared state should not re-emit the origin chart's event.
Keyboard and accessibility defaults
Name charts with ariaLabel (subject or takeaway — not generic image alt). Focus the plot, then use arrow keys or brackets to traverse data. Enter or Space pins inspection, activates point selection, or sets the two corners of an area, depending on the active tool. Escape dismisses the current interaction. Keyboard inspection updates a polite live region with a concise axis, count, and pin summary; complete pinned content remains ordinary labelled and navigable DOM. Canvas marks keep SVG axes/legends and the accessible description path.
Identity and diagnostics
Use key="id" when the row has a field, or key={(row) => row.id} for an accessor. Keys must be non-null unique PropertyKey values and stable across updates. Invalid or duplicate keys emit structured diagnostics through ondiagnostic; they never silently fall back to array positions. Stable keys let pinned inspection and point selection follow a datum when data is updated.