Every part of the rendered tree can be replaced. There are three levels, in order of increasing reach:
- Translations — change the text (or markup) of a label or tooltip.
- Snippets and
controls— replace an individual control. - Context — apply either of the above to every query builder in a subtree.
Before replacing a component, check whether styling gets you there.
translations overrides the text of every label, title, and placeholder. Labels are typed LabelNode = Snippet | string, so any label can be either plain text or a snippet:
<script lang="ts">
import { QueryBuilder } from 'svelte-querybuilder';
let query = $state({ combinator: 'and', rules: [] });
</script>
{#snippet addRuleLabel()}
<span aria-hidden="true">+</span> Add rule
{/snippet}
<QueryBuilder
{fields}
bind:query
translations={{
addRule: { label: addRuleLabel, title: 'Add a rule to this group' },
fields: { placeholderLabel: 'Choose a field…' },
}} />Titles are plain strings — they end up in a title attribute, which cannot hold markup.
Every control has one name — valueEditor, removeRuleAction, ruleGroup, and so on — and two ways to supply a replacement: a snippet on the top-level prop of that name, or a component in the controls object.
The split is not arbitrary. Snippets and components are both plain functions at runtime with no reliable way to tell them apart, so each channel is typed for exactly one kind. Snippets get the top-level prop because a {#snippet} declared inside a component's tags only becomes a prop when the name is top-level — it cannot populate a nested object.
The snippet takes one argument: the props object the default component would have received.
<QueryBuilder {fields} bind:query>
{#snippet valueEditor(props)}
<input
class={props.className}
value={props.value}
disabled={props.disabled}
oninput={e => props.handleOnChange(e.currentTarget.value)} />
{/snippet}
</QueryBuilder>Snippets are the better fit when the replacement is small, needs values from the surrounding scope, or is only used once.
Pass a Svelte component instead. Better fit when the replacement is reusable or needs its own state:
<script lang="ts">
import MyValueEditor from './MyValueEditor.svelte';
</script>
<QueryBuilder {fields} bind:query controls={{ valueEditor: MyValueEditor }} />null renders nothing:
<QueryBuilder {fields} bind:query controls={{ lockRuleAction: null }} />A snippet can go in controls too, wrapped in { snippet }, for configuration assembled programmatically:
<QueryBuilder {fields} bind:query controls={{ valueEditor: { snippet: myRawSnippet } }} />actionElement replaces every button-type control at once (addRuleAction, removeGroupAction, cloneRuleAction, …), and valueSelector replaces every <select>-type control (fieldSelector, operatorSelector, combinatorSelector, valueSourceSelector). Both work as a snippet prop or a controls entry. Neither applies to valueEditor, rule, ruleGroup, inlineCombinator, notToggle, or matchModeEditor.
Which controls are "actions" and which are "selectors" comes from core's controlKind map, not from the shape of the name — shiftActions and undoRedoActions are composites and are not bulk-action targets despite the plural suffix.
Each control key is resolved independently. Levels are tried in order — props, then inherited context, then the package defaults — and within a level:
- the keyed snippet (the
valueEditorprop) - the keyed entry (
controls.valueEditor), wherenullmeans "render nothing" and stops the search - the bulk snippet (the
valueSelectorprop) - the bulk entry (
controls.valueSelector)
So a snippet passed to QueryBuilder beats a component passed to QueryBuilder, which beats anything inherited from context, which beats the default.
Context carries configuration — controls, controlClassnames, translations, and the boolean flags — down to every query builder below it, including the subquery builders that match modes create.
A query builder publishes its resolved controls to its descendants, so a nested builder inherits whatever the outer one ended up with, and still overrides it per key with its own props.
<script lang="ts">
import { setQueryBuilderContext } from 'svelte-querybuilder';
import MyValueEditor from './MyValueEditor.svelte';
// `setQueryBuilderContext` takes a *getter*, not a value.
setQueryBuilderContext(() => ({
controls: { valueEditor: MyValueEditor },
translations: { addRule: { label: 'Add' } },
showNotToggle: true,
}));
</script>Context is set once, during component initialization, so the argument is a getter rather than a value. Descendants call it from inside their own derivations, which is what keeps reactive values live:
let showNotToggle = $state(true); // Read inside the getter, so descendants see every change.
setQueryBuilderContext(() => ({showNotToggle}));Props always win over context, per key.
Replacement components receive the same props the default does; the types are exported from the package barrel:
<!-- MyValueEditor.svelte -->
<script lang="ts">
import type { ValueEditorProps } from 'svelte-querybuilder';
const props: ValueEditorProps = $props();
</script>
<input
data-testid={props.testID}
class={props.className}
title={props.title}
value={props.value}
disabled={props.disabled}
oninput={e => props.handleOnChange(e.currentTarget.value)} />Keep data-testid, class, and title if you want the standard stylesheets and any tests written against the standard DOM to keep working.
Replacing rule or ruleGroup wholesale is a larger job, because those components own the class names, the accessible description, and the child paths. Rather than recomputing any of that, use createRuleParts/createRuleGroupParts. Both take a props getter:
<script lang="ts">
import { createRuleParts, type RuleProps } from 'svelte-querybuilder';
const props: RuleProps = $props();
const parts = createRuleParts(() => props);
</script>
<div class={parts.outerClassName} data-path={JSON.stringify(props.path)}>
<!-- … -->
</div>Hold the query yourself and bind it. There is no manager prop — query state is a rune owned by the component, and bind:query is the supported way in and out:
<script lang="ts">
import { QueryBuilder, add } from 'svelte-querybuilder';
let query = $state({ combinator: 'and', rules: [] });
// Core's pure query tools are re-exported from the barrel. `freeze: false` because immer's
// deep freeze throws on the Svelte `$state` proxies the query is made of.
const addRule = () =>
(query = add(query, { field: 'firstName', operator: '=', value: '' }, [], { freeze: false }));
</script>
<button onclick={addRule}>Add rule</button>
<QueryBuilder {fields} bind:query />Undo/redo is internal to the component; render its controls with showUndoRedo rather than driving it from outside.
controlClassnames appends to the standard classes rather than replacing them, so queryBuilder-invalid and friends keep working:
<QueryBuilder {fields} bind:query controlClassnames={{ rule: 'my-rule', queryBuilder: 'my-qb' }} />To drop the standard classes entirely, pass suppressStandardClassnames.