npm install @questify/core

The headless questionnaire
engine for the web.

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.

v1.0.0·~2 KB gzip·TypeScript·MIT

Conditional form — accordion mode

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.

4 questions visible·0 answered
What type of insurance are you looking for?
Who are you purchasing this insurance for?
Have you made any insurance claims in the last 3 years?
What is your preferred monthly premium range?

Branching wizard — step-by-step

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.

What is your full legal name?

Built around real pain points

Zero dependencies

The core ships as ~2 KB min+gzip. No runtime bloat, no peer pressure.

🔀

Compound conditions

showIf supports AND / OR trees that nest arbitrarily deep — exactly how real insurance and onboarding forms work.

🎯

Headless by design

No UI shipped. Bring your own design system — Shadcn, Radix, Chakra, raw CSS.

🔌

Framework adapters

First-class React hook and Vue composable. Core class works in Node, React Native, anywhere.

Per-type validation

Required, min/max, minLength/maxLength, regex, email format — runs per question type automatically.

📦

Tree-shakeable

Three separate entry points: questify, questify/react, questify/vue. Import only what you need.

Install

npm install @questify/core

useQuestionnaire hook

Every 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 composable

Same 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 class

The 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 nested

A 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' },
      ],
    },
  ],
}

State shape

Everything returned by useQuestionnaire or emitted by subscribe().

PropertyTypeDescription
questionQuestion | nullCurrent question being shown. Null only if questions array is empty.
visibleQuestionsQuestion[]All currently visible questions after resolving showIf conditions.
questionIndexnumber0-based index into visibleQuestions.
totalQuestionsnumberLength of visibleQuestions — updates in real-time as conditions change.
progressnumber (0–1)Fraction of visible questions that have a non-empty answer.
responsesRecord<string, unknown>All answers collected so far, keyed by question id.
isCompletebooleanTrue when all required visible questions have valid answers.
errorsRecord<string, string>Validation error messages per question id.
canGoBackbooleanFalse on the first question.
canGoNextbooleanFalse on the last visible question.