A headless Vue 3 component library for building powerful and customizable query builders with complete UI freedom.
- 🎨 Fully customizable UI: Style it any way you want - no pre-defined styles to override
- 🧩 Headless components: Functional wrappers with slots that handle the logic, you provide the UI
- 🔌 Composable API: Simple component structure that's easy to understand and extend
- 🧠 Smart state management: Built-in logic for handling complex query structures
- 🛠️ TypeScript support: Full type definitions for excellent developer experience
- ⚡ Vue 3 and Composition API: Built with the latest Vue features
# npm
npm install @sketchmonk/query-builder-vue
# yarn
yarn add @sketchmonk/query-builder-vue
# pnpm
pnpm add @sketchmonk/query-builder-vue
Unlike most query builder libraries that come with predefined UI, this library focuses exclusively on the logic, allowing you to:
- Create a query builder that perfectly matches your application's design system
- Implement custom field types and operators specific to your needs
- Control every aspect of the user experience without fighting against default styles
- Build accessible components tailored to your requirements
Here's a basic example of how to use the library:
<script setup lang="ts">
import { ref } from 'vue';
import {
QueryBuilderRoot,
type Query,
type QueryBuilderConfig
} from '@sketchmonk/query-builder-vue';
// Define your query structure
const query = ref<Query>({
combinator: 'and',
rules: [],
not: false,
});
// Configure your fields and operators
const config: QueryBuilderConfig = {
defaultCombinator: 'and',
fields: [
{
name: 'name',
label: 'Name',
type: 'string',
operators: [
{ name: 'equal', label: 'Equal' },
{ name: 'contains', label: 'Contains' }
],
defaultOperator: 'equal',
},
{
name: 'age',
label: 'Age',
type: 'number',
operators: [
{ name: 'equal', label: 'Equal' },
{ name: 'greater_than', label: 'Greater than' },
{ name: 'less_than', label: 'Less than' }
],
defaultOperator: 'equal',
}
]
};
</script>
<template>
<QueryBuilderRoot
:config="config"
v-model:query="query"
v-slot="{ value, onChange }">
<!-- Implement your custom UI here -->
<div class="your-custom-query-builder">
<!-- Your UI components here -->
</div>
</QueryBuilderRoot>
</template>
The library provides the following core components:
QueryBuilderRoot
: The main component that wraps everything and provides contextQueryBuilderRule
: Represents a single rule with field, operator, and valueQueryBuilderRuleGroup
: Represents a group of rules with a combinatorQueryBuilderRuleFieldSelector
: For selecting a fieldQueryBuilderRuleOperatorSelector
: For selecting an operatorQueryBuilderRuleValueSelector
: For inputting a valueQueryBuilderCombinator
: For selecting a logical combinator (AND/OR)
Here's a more complete example showing how to build a custom UI:
<script setup lang="ts">
import { ref, computed } from 'vue';
import {
QueryBuilderRoot,
QueryBuilderRuleGroup,
QueryBuilderRule,
QueryBuilderRuleFieldSelector,
QueryBuilderRuleOperatorSelector,
QueryBuilderRuleValueSelector,
QueryBuilderCombinator,
type Query,
type QueryBuilderConfig
} from '@sketchmonk/query-builder-vue';
const query = ref<Query>({
combinator: 'and',
rules: [],
not: false,
});
const config = computed<QueryBuilderConfig>(() => ({
defaultCombinator: 'and',
fields: [
{
name: 'name',
label: 'Name',
type: 'string',
operators: [
{ name: 'equal', label: 'Equal' },
{ name: 'contains', label: 'Contains' }
],
defaultOperator: 'equal',
defaultValue: () => '',
},
{
name: 'age',
label: 'Age',
type: 'number',
operators: [
{ name: 'equal', label: 'Equal' },
{ name: 'greater_than', label: 'Greater than' }
],
defaultOperator: 'equal',
defaultValue: () => 18,
}
]
}));
</script>
<template>
<QueryBuilderRoot
:config="config"
v-model:query="query"
v-slot="{ value, onChange }">
<div class="query-builder">
<QueryBuilderRuleGroup
:model-value="value"
@update:model-value="onChange"
v-slot="{ rules, onAddRule, onAddGroup, onRemoveRule, onUpdateRule }">
<div class="rule-group">
<QueryBuilderCombinator v-slot="{ value, onChange }">
<select :value="value" @change="onChange(($event.target as HTMLSelectElement).value as any)">
<option value="and">AND</option>
<option value="or">OR</option>
</select>
</QueryBuilderCombinator>
<div class="rules">
<div v-for="(rule, index) in rules" :key="index" class="rule-container">
<!-- For a Rule -->
<template v-if="rule.type === 'rule'">
<QueryBuilderRule
:model-value="rule"
@update:model-value="onUpdateRule(index, $event)">
<div class="rule">
<QueryBuilderRuleFieldSelector v-slot="{ fields, value, onChange }">
<select :value="value" @change="onChange(($event.target as HTMLSelectElement).value)">
<option v-for="field in fields" :key="field.name" :value="field.name">
{{ field.label }}
</option>
</select>
</QueryBuilderRuleFieldSelector>
<QueryBuilderRuleOperatorSelector v-slot="{ operators, value, onChange }">
<select :value="value" @change="onChange(($event.target as HTMLSelectElement).value)">
<option v-for="op in operators" :key="op.name" :value="op.name">
{{ op.label }}
</option>
</select>
</QueryBuilderRuleOperatorSelector>
<QueryBuilderRuleValueSelector v-slot="{ field, value, onChange }">
<input
v-if="field?.type === 'string'"
:value="value"
@input="onChange(($event.target as HTMLInputElement).value)"
/>
<input
v-else-if="field?.type === 'number'"
type="number"
:value="value"
@input="onChange(Number(($event.target as HTMLInputElement).value))"
/>
</QueryBuilderRuleValueSelector>
<button @click="onRemoveRule(index)">Remove</button>
</div>
</QueryBuilderRule>
</template>
<!-- For a nested Group -->
<template v-else-if="rule.type === 'group'">
<!-- Recursive group rendering -->
</template>
</div>
<div class="actions">
<button @click="onAddRule()">Add Rule</button>
<button @click="onAddGroup()">Add Group</button>
</div>
</div>
</div>
</QueryBuilderRuleGroup>
</div>
</QueryBuilderRoot>
</template>
<style scoped>
/* Your custom styles here */
</style>
The configuration object defines fields, operators, and default behaviors:
interface QueryBuilderConfig {
// Available fields in the query builder
fields: QueryField[];
// Default combinator for new rule groups
defaultCombinator: 'and' | 'or';
}
interface QueryField<TValue = any, TMeta = any> {
// Unique identifier for the field
name: string;
// Display label
label: string;
// Field data type (string, number, date, etc.)
type: string;
// Available operators for this field
operators: QueryOperator[];
// Default operator when creating a new rule
defaultOperator?: string;
// Function to provide default values
defaultValue?: ((ctx: { operator: QueryOperator }) => ValueType<TValue>);
// Additional custom metadata
meta?: TMeta;
}
interface QueryOperator<TMeta = any> {
// Unique identifier
name: string;
// Display label
label: string;
// Whether multiple values are allowed
isMulti?: boolean;
// Whether range values are allowed
isRange?: boolean;
// Additional custom metadata
meta?: TMeta;
}
The query is structured as a tree of rules and groups:
interface Query {
// Logical combination of rules (and/or)
combinator: 'and' | 'or';
// Whether to negate the entire group
not: boolean;
// Array of rules or nested groups
rules: (Rule | RuleGroup)[];
}
interface Rule<TValue = any> {
type: 'rule';
id: string;
field: string;
operator: string;
value: TValue | TValue[] | { from: TValue; to: TValue };
}
interface RuleGroup {
type: 'group';
id: string;
combinator: 'and' | 'or';
not: boolean;
rules: (Rule | RuleGroup)[];
}
You can define any custom field types in your configuration:
const config: QueryBuilderConfig = {
defaultCombinator: 'and',
fields: [
// Basic types
{
name: 'name',
label: 'Name',
type: 'string',
operators: [/* ... */],
},
// Choice fields with options
{
name: 'status',
label: 'Status',
type: 'choice',
operators: [/* ... */],
meta: {
values: [
{ value: 'active', label: 'Active' },
{ value: 'inactive', label: 'Inactive' },
]
}
},
// Date fields
{
name: 'created_at',
label: 'Created At',
type: 'date',
operators: [/* ... */],
},
// Custom types
{
name: 'location',
label: 'Location',
type: 'geo',
operators: [/* ... */],
}
]
};
- Separate UI components: Create reusable UI components for different field types
- Normalize data: Standardize how you handle different value types
- Maintain state: Use
v-model
anddefineModel
to keep state in sync - Handle validation: Implement your own validation logic for fields
- Accessibility: Ensure your custom UI is accessible
For complex field types, you can create dedicated components:
<QueryBuilderRuleValueSelector v-slot="{ field, value, onChange, disabled }">
<template v-if="field?.type === 'string'">
<input :disabled="disabled" :value="value" @input="onChange($event.target.value)" />
</template>
<template v-else-if="field?.type === 'date'">
<DatePicker :value="value" @change="onChange" :disabled="disabled" />
</template>
<template v-else-if="field?.type === 'geo'">
<GeoLocationPicker :value="value" @change="onChange" :disabled="disabled" />
</template>
</QueryBuilderRuleValueSelector>
You can change available fields based on context:
<script setup>
const config = computed(() => ({
fields: isAdmin.value
? [...adminFields, ...regularFields]
: regularFields
}));
</script>
Contributions are welcome! Please feel free to submit a Pull Request.