A zero-dependency state machine for multi-step forms, compound conditional logic, and per-type validation. Works with React, Vue, or plain JavaScript. Headless by design — you bring the components.
All questions rendered at once in a collapsible list. Expand any field to answer it — conditional follow-ups appear inline below immediately, no rerenders of sibling questions. Each ⤷ marks a question that only appears when its condition is met.
Dozens of questions — but the form adapts in real time. Most users see fewer than half, depending on their answers. Try different selections to watch branches appear and disappear, compound AND conditions trigger, and progress update live.
The core ships as ~2 KB min+gzip. No runtime bloat, no peer pressure.
showIf supports AND / OR trees that nest arbitrarily deep — exactly how real insurance and onboarding forms work.
No UI shipped. Bring your own design system — Shadcn, Radix, Chakra, raw CSS.
First-class React hook and Vue composable. Core class works in Node, React Native, anywhere.
Required, min/max, minLength/maxLength, regex, email format — runs per question type automatically.
Three separate entry points: questify, questify/react, questify/vue. Import only what you need.
npm install @questify/coreuseQuestionnaire hookEvery value in the returned object is reactive. Pass answer(value) directly to your input's onChange. Call next() to advance — it validates required fields first and populates errors if invalid.
import { useQuestionnaire } from '@questify/core/react';
const questions = [
{
id: 'age',
text: 'How old are you?',
type: 'number',
required: true,
validation: { min: 18, max: 99 },
},
{
id: 'smoker',
text: 'Do you currently smoke?',
type: 'boolean',
required: true,
},
// Only shown when age >= 50 AND smoker = true
{
id: 'chest_scan',
text: 'Have you had a chest CT scan in the last 2 years?',
type: 'boolean',
showIf: {
and: [
{ questionId: 'age', value: 50, operator: 'gte' },
{ questionId: 'smoker', value: true },
],
},
},
];
export function HealthForm() {
const {
question, // current Question object
questionIndex, // 0-based step index
totalQuestions,// total visible questions (conditional ones resolved)
progress, // 0–1 float for progress bar
responses, // { [questionId]: value }
isComplete, // all required visible questions answered
errors, // { [questionId]: errorMessage }
canGoBack,
canGoNext,
answer, // answer(value) — call on every input change
next, // advance (validates required before moving)
back,
reset,
} = useQuestionnaire({ questions });
return (
<div>
<progress value={progress} max={1} />
<p>Step {questionIndex + 1} of {totalQuestions}</p>
<h2>{question?.text}</h2>
{/* Render your own input based on question.type */}
<button onClick={back} disabled={!canGoBack}>Back</button>
{canGoNext
? <button onClick={next}>Next</button>
: <button disabled={!isComplete}>Submit</button>
}
</div>
);
}useQuestionnaire composableSame API as React — all returned values are computed refs.
import { useQuestionnaire } from '@questify/core/vue';
// In your <script setup>
const {
question, progress, responses, isComplete,
errors, canGoBack, canGoNext,
answer, next, back, reset,
} = useQuestionnaire({ questions });Questionnaire classThe raw state machine. Framework adapters are thin wrappers around this. Subscribe to state changes with a callback — returns an unsubscribe function.
import { Questionnaire } from '@questify/core';
const q = new Questionnaire({ questions });
const unsubscribe = q.subscribe((state) => {
console.log('Step:', state.questionIndex + 1, '/', state.totalQuestions);
console.log('Current question:', state.question?.text);
console.log('Visible questions:', state.visibleQuestions.length);
console.log('Progress:', Math.round(state.progress * 100) + '%');
});
// Answer the current question
q.answer('Alice');
// Move forward (validates required fields first)
q.next();
// Jump to a specific step
q.jumpTo(3);
// Tear down
unsubscribe();showIf — simple to arbitrarily nestedA single showIf field controls visibility. It accepts a simple condition, or a compound and/or tree. Trees can be nested infinitely.
// ─ Simple condition ───────────────────────────────────────────
showIf: { questionId: 'smoker', value: true }
// ─ Operator variants ──────────────────────────────────────────
showIf: { questionId: 'bmi', value: 30, operator: 'gte' }
showIf: { questionId: 'conditions', value: 'diabetes', operator: 'includes' }
// ─ AND compound ───────────────────────────────────────────────
// Show only when patient is a smoker AND has heart disease
showIf: {
and: [
{ questionId: 'smoker', value: true },
{ questionId: 'conditions', value: 'heart', operator: 'includes' },
],
}
// ─ OR compound ────────────────────────────────────────────────
// Show when rating is low OR user said they wouldn't recommend
showIf: {
or: [
{ questionId: 'rating', value: 3, operator: 'lte' },
{ questionId: 'recommend', value: false },
],
}
// ─ Nested AND inside OR ────────────────────────────────────────
showIf: {
or: [
{ questionId: 'cancer', value: true },
{
and: [
{ questionId: 'smoker', value: true },
{ questionId: 'age', value: 50, operator: 'gte' },
],
},
],
}Everything returned by useQuestionnaire or emitted by subscribe().
| Property | Type | Description |
|---|---|---|
question | Question | null | Current question being shown. Null only if questions array is empty. |
visibleQuestions | Question[] | All currently visible questions after resolving showIf conditions. |
questionIndex | number | 0-based index into visibleQuestions. |
totalQuestions | number | Length of visibleQuestions — updates in real-time as conditions change. |
progress | number (0–1) | Fraction of visible questions that have a non-empty answer. |
responses | Record<string, unknown> | All answers collected so far, keyed by question id. |
isComplete | boolean | True when all required visible questions have valid answers. |
errors | Record<string, string> | Validation error messages per question id. |
canGoBack | boolean | False on the first question. |
canGoNext | boolean | False on the last visible question. |