npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@superbuilders/incept-renderer

v0.1.14

Published

QTI 3.0 Assessment Renderer - Parse, validate, and render QTI XML with customizable themes

Readme

@superbuilders/incept-renderer

A secure, server-driven QTI 3.0 assessment renderer for React/Next.js applications. Renders interactive questions with built-in validation while keeping correct answers secure on the server.

Table of Contents


Installation

npm install @superbuilders/incept-renderer
# or
bun add @superbuilders/incept-renderer
# or
yarn add @superbuilders/incept-renderer

Peer Dependencies

This package requires:

  • react >= 18.0.0
  • react-dom >= 18.0.0

For full functionality with Tailwind CSS theming, ensure your app has Tailwind configured.


Core Concepts

Security Model

Correct answers NEVER leave the server. This package uses a secure architecture where:

  1. Server parses XML → Extracts only display data (questions, choices, prompts)
  2. Client renders UI → Users interact with questions
  3. Server validates → Checks answers against the original XML
  4. Client shows feedback → Displays correctness and feedback messages
┌─────────────────────────────────────────────────────────────────┐
│  XML Source (your database/API)                                 │
│  Contains: questions, choices, AND correct answers              │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  SERVER: buildDisplayModel(xml)                                 │
│  Extracts: questions, choices, prompts                          │
│  EXCLUDES: correct answers, response processing rules           │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  CLIENT: QTIRenderer                                            │
│  Shows: questions, choices, user can select answers             │
│  Cannot see: correct answers (never sent to browser)            │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼ User clicks "Check"
┌─────────────────────────────────────────────────────────────────┐
│  SERVER: validateResponsesSecure(xml, userResponses)            │
│  Compares: user answers against correct answers                 │
│  Returns: isCorrect, feedback HTML, per-choice correctness      │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│  CLIENT: Shows feedback                                         │
│  Green/red highlights, feedback messages, scores                │
└─────────────────────────────────────────────────────────────────┘

Quick Start

1. Create the Server Component (page.tsx)

// app/quiz/[id]/page.tsx
import * as React from "react"
import { buildDisplayModel, validateResponsesSecure } from "@superbuilders/incept-renderer/actions"
import { QuizClient } from "./client"

export default function QuizPage({ params }: { params: Promise<{ id: string }> }) {
  // Fetch your QTI XML and build the display model
  const displayPromise = params.then(async (p) => {
    const xml = await getQtiXmlFromYourDatabase(p.id)
    return buildDisplayModel(xml)
  })

  // Create a server action for validation
  async function validateAnswers(responses: Record<string, string | string[]>) {
    "use server"
    const { id } = await params
    const xml = await getQtiXmlFromYourDatabase(id)
    return validateResponsesSecure(xml, responses)
  }

  return (
    <React.Suspense fallback={<div>Loading question...</div>}>
      <QuizClient displayPromise={displayPromise} onValidate={validateAnswers} />
    </React.Suspense>
  )
}

// Your function to get QTI XML from wherever you store it
async function getQtiXmlFromYourDatabase(id: string): Promise<string> {
  // Examples:
  // - Database: await db.query.questions.findFirst({ where: eq(id, questionId) })
  // - REST API: await fetch(`https://api.example.com/questions/${id}`).then(r => r.text())
  // - File system: await fs.readFile(`./questions/${id}.xml`, 'utf-8')
  throw new Error("Implement this function")
}

2. Create the Client Component (client.tsx)

// app/quiz/[id]/client.tsx
"use client"

import * as React from "react"
import { QTIRenderer } from "@superbuilders/incept-renderer"
import type { DisplayItem, FormShape, ValidateResult } from "@superbuilders/incept-renderer"

interface QuizClientProps {
  displayPromise: Promise<{ item: DisplayItem; shape: FormShape; itemKey: string }>
  onValidate: (responses: Record<string, string | string[]>) => Promise<ValidateResult>
}

export function QuizClient({ displayPromise, onValidate }: QuizClientProps) {
  // Unwrap the promise from the server
  const { item, shape, itemKey } = React.use(displayPromise)

  // State management
  const [responses, setResponses] = React.useState<Record<string, string | string[]>>({})
  const [result, setResult] = React.useState<ValidateResult | undefined>()
  const [showFeedback, setShowFeedback] = React.useState(false)
  const [isChecking, setIsChecking] = React.useState(false)

  // Handle user selecting/changing answers
  const handleResponseChange = (responseId: string, value: string | string[]) => {
    setResponses(prev => ({ ...prev, [responseId]: value }))
  }

  // Handle "Check Answer" click
  const handleCheck = async () => {
    setIsChecking(true)
    const validation = await onValidate(responses)
    setResult(validation)
    setShowFeedback(true)
    setIsChecking(false)
  }

  // Handle "Try Again" click
  const handleTryAgain = () => {
    setShowFeedback(false)
    setResult(undefined)
  }

  return (
    <div className="max-w-2xl mx-auto p-4">
      {/* The QTI Renderer */}
      <QTIRenderer
        item={item}
        responses={responses}
        onResponseChange={handleResponseChange}
        showFeedback={showFeedback}
        disabled={showFeedback}
        overallFeedback={result ? {
          isCorrect: result.overallCorrect,
          messageHtml: result.feedbackHtml
        } : undefined}
        choiceCorrectness={result?.selectedChoicesByResponse}
        responseFeedback={result?.perResponse}
      />

      {/* Your app's buttons */}
      <div className="mt-6 flex gap-4">
        {!showFeedback ? (
          <button
            onClick={handleCheck}
            disabled={isChecking || Object.keys(responses).length === 0}
            className="bg-green-500 text-white px-6 py-3 rounded-lg font-bold disabled:opacity-50"
          >
            {isChecking ? "Checking..." : "Check Answer"}
          </button>
        ) : result?.overallCorrect ? (
          <button
            onClick={() => window.location.href = "/next-question"}
            className="bg-green-500 text-white px-6 py-3 rounded-lg font-bold"
          >
            Continue →
          </button>
        ) : (
          <button
            onClick={handleTryAgain}
            className="bg-blue-500 text-white px-6 py-3 rounded-lg font-bold"
          >
            Try Again
          </button>
        )}
      </div>
    </div>
  )
}

Architecture

File Structure Pattern

app/quiz/[id]/
├── page.tsx      # Server Component: fetches XML, creates validation action
└── client.tsx    # Client Component: renders QTIRenderer, manages state

Data Flow

1. User visits /quiz/123
       │
       ▼
2. page.tsx (Server)
   - Fetches QTI XML from your data source
   - Calls buildDisplayModel(xml)
   - Passes display data to client
       │
       ▼
3. client.tsx (Client)
   - Renders QTIRenderer with display data
   - User interacts with choices
       │
       ▼
4. User clicks "Check Answer"
   - Client calls onValidate(responses)
   - Server action validates against original XML
   - Returns { overallCorrect, feedbackHtml, selectedChoicesByResponse }
       │
       ▼
5. Client shows feedback
   - QTIRenderer shows green/red highlights
   - Feedback message displayed

API Reference

Server Functions

Import from @superbuilders/incept-renderer/actions:

buildDisplayModel(xml: string)

Parses QTI XML and extracts display-safe data.

import { buildDisplayModel } from "@superbuilders/incept-renderer/actions"

const { item, shape, itemKey } = await buildDisplayModel(qtiXmlString)

Parameters:

  • xml (string) - Raw QTI 3.0 XML string

Returns:

{
  itemKey: string          // Unique identifier for caching
  item: DisplayItem        // Parsed question data for rendering
  shape: FormShape         // Schema describing expected response structure
}

validateResponsesSecure(xml: string, responses: Record<string, string | string[]>)

Validates user responses against the QTI XML.

import { validateResponsesSecure } from "@superbuilders/incept-renderer/actions"

const result = await validateResponsesSecure(qtiXmlString, {
  RESPONSE: "choice_A"
})

Parameters:

  • xml (string) - Raw QTI 3.0 XML string (same as used for buildDisplayModel)
  • responses (Record<string, string | string[]>) - User's selected answers

Returns:

{
  overallCorrect: boolean   // true if all responses are correct
  feedbackHtml: string      // Combined HTML feedback message
  selectedChoicesByResponse: Record<string, Array<{ id: string; isCorrect: boolean }>>
  perResponse: Record<string, { isCorrect: boolean; messageHtml?: string }>
}

parseAssessmentStimulusXml(xml: string)

Parses QTI Assessment Stimulus XML (reading materials, passages).

import { parseAssessmentStimulusXml } from "@superbuilders/incept-renderer"

const stimulus = parseAssessmentStimulusXml(stimulusXmlString)

Parameters:

  • xml (string) - Raw QTI 3.0 Assessment Stimulus XML string

Returns:

{
  identifier: string   // Unique identifier from the XML
  title: string        // Human-readable title
  xmlLang: string      // Language code (e.g., "en")
  bodyHtml: string     // Sanitized HTML content ready for rendering
}

Client Components

Import from @superbuilders/incept-renderer:

<QTIRenderer />

The main component for rendering QTI questions.

import { QTIRenderer } from "@superbuilders/incept-renderer"

<QTIRenderer
  item={displayItem}
  responses={responses}
  onResponseChange={handleChange}
  showFeedback={showFeedback}
  disabled={isDisabled}
  overallFeedback={feedbackData}
  choiceCorrectness={correctnessMap}
  responseFeedback={perResponseFeedback}
  theme="duolingo"
/>

Props:

| Prop | Type | Required | Description | |------|------|----------|-------------| | item | DisplayItem | ✅ | Parsed question data from buildDisplayModel | | responses | Record<string, string \| string[]> | ✅ | Current user responses | | onResponseChange | (id: string, value: string \| string[]) => void | ✅ | Called when user selects/changes an answer | | showFeedback | boolean | ❌ | Whether to show correct/incorrect feedback | | disabled | boolean | ❌ | Disable all interactions | | overallFeedback | { isCorrect: boolean; messageHtml?: string } | ❌ | Overall feedback after validation | | choiceCorrectness | Record<string, Array<{ id: string; isCorrect: boolean }>> | ❌ | Per-choice correctness for highlighting | | responseFeedback | Record<string, { isCorrect: boolean; messageHtml?: string }> | ❌ | Per-response feedback messages | | theme | "duolingo" \| "neobrutalist" \| string | ❌ | Visual theme (default: "duolingo") |

<QTIStimulusRenderer />

Renders QTI Assessment Stimuli (reading materials, passages).

import { QTIStimulusRenderer } from "@superbuilders/incept-renderer"

<QTIStimulusRenderer
  stimulus={parsedStimulus}
  className="my-8"
/>

Props:

| Prop | Type | Required | Description | |------|------|----------|-------------| | stimulus | AssessmentStimulus | ✅ | Parsed stimulus from parseAssessmentStimulusXml | | className | string | ❌ | Additional CSS classes for the container |


Types

Import from @superbuilders/incept-renderer:

import type {
  // Assessment Items (questions)
  DisplayItem,
  DisplayBlock,
  DisplayChoice,
  DisplayChoiceInteraction,
  FormShape,
  ValidateResult,
  // Assessment Stimuli (reading materials)
  AssessmentStimulus
} from "@superbuilders/incept-renderer"

DisplayItem

interface DisplayItem {
  identifier: string
  title: string
  blocks: DisplayBlock[]
}

DisplayBlock

type DisplayBlock =
  | { type: "html"; html: string }
  | { type: "interaction"; interaction: DisplayChoiceInteraction }

DisplayChoiceInteraction

interface DisplayChoiceInteraction {
  responseIdentifier: string
  type: "choice"
  maxChoices: number
  prompt?: string
  choices: DisplayChoice[]
}

DisplayChoice

interface DisplayChoice {
  identifier: string
  contentHtml: string
}

FormShape

interface FormShape {
  responses: Record<string, {
    cardinality: "single" | "multiple"
    baseType: string
  }>
}

ValidateResult

interface ValidateResult {
  overallCorrect: boolean
  feedbackHtml: string
  selectedChoicesByResponse: Record<string, Array<{ id: string; isCorrect: boolean }>>
  perResponse: Record<string, { isCorrect: boolean; messageHtml?: string }>
}

AssessmentStimulus

interface AssessmentStimulus {
  identifier: string   // Unique identifier from the XML
  title: string        // Human-readable title  
  xmlLang: string      // Language code (e.g., "en", "es")
  bodyHtml: string     // Sanitized HTML content
}

Rendering Stimuli

In addition to assessment items (questions), the package supports rendering Assessment Stimuli — reading materials, passages, or articles that provide context for questions.

What are Stimuli?

QTI Assessment Stimuli (qti-assessment-stimulus) are standalone content blocks that contain:

  • Reading passages
  • Articles with images
  • Reference materials
  • Any HTML content that accompanies questions

Unlike assessment items, stimuli have no interactions or correct answers — they're purely display content.

Parsing Stimulus XML

Use parseAssessmentStimulusXml to parse stimulus XML:

import { parseAssessmentStimulusXml } from "@superbuilders/incept-renderer"

const stimulusXml = `<?xml version="1.0" encoding="UTF-8"?>
<qti-assessment-stimulus 
  xmlns="http://www.imsglobal.org/xsd/imsqtiasi_v3p0"
  identifier="stimulus-1" 
  xml:lang="en" 
  title="Biodiversity and Ecosystem Health">
  <qti-stimulus-body>
    <h2>Biodiversity and ecosystem health</h2>
    <p><strong>Biodiversity</strong> is the variety of species in an ecosystem.</p>
    <figure>
      <img src="coral-reef.jpg" alt="A coral reef" />
      <figcaption>Coral reef ecosystems have high biodiversity.</figcaption>
    </figure>
  </qti-stimulus-body>
</qti-assessment-stimulus>`

const stimulus = parseAssessmentStimulusXml(stimulusXml)
// Returns: { identifier, title, xmlLang, bodyHtml }

Rendering Stimuli

Use the QTIStimulusRenderer component to render parsed stimuli:

import { QTIStimulusRenderer, parseAssessmentStimulusXml } from "@superbuilders/incept-renderer"

function ReadingPassage({ xml }: { xml: string }) {
  const stimulus = parseAssessmentStimulusXml(xml)
  
  return (
    <QTIStimulusRenderer 
      stimulus={stimulus} 
      className="my-8"
    />
  )
}

Server Action Pattern

For Next.js apps, create a server action to parse stimuli:

// lib/qti-actions.ts
"use server"

import { parseAssessmentStimulusXml } from "@superbuilders/incept-renderer"

export async function parseStimulus(xml: string) {
  return parseAssessmentStimulusXml(xml)
}
// app/reading/[id]/page.tsx
import { parseStimulus } from "@/lib/qti-actions"
import { QTIStimulusRenderer } from "@superbuilders/incept-renderer"

export default async function ReadingPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const xml = await fetchStimulusXml(id) // Your data fetching
  const stimulus = await parseStimulus(xml)
  
  return <QTIStimulusRenderer stimulus={stimulus} />
}

Stimulus with Questions

A common pattern is showing a stimulus alongside related questions:

import { QTIStimulusRenderer, QTIRenderer } from "@superbuilders/incept-renderer"

function QuestionWithPassage({ stimulus, item, ...props }) {
  return (
    <div className="grid grid-cols-2 gap-8">
      {/* Reading passage on the left */}
      <div className="overflow-y-auto max-h-[80vh]">
        <QTIStimulusRenderer stimulus={stimulus} />
      </div>
      
      {/* Question on the right */}
      <div>
        <QTIRenderer item={item} {...props} />
      </div>
    </div>
  )
}

AssessmentStimulus Type

interface AssessmentStimulus {
  identifier: string    // Unique ID from the XML
  title: string         // Human-readable title
  xmlLang: string       // Language code (e.g., "en")
  bodyHtml: string      // Sanitized HTML content
}

Supported Content

The stimulus renderer supports:

  • Headings (h1h6)
  • Text formatting (p, strong, em, b, i, u, sub, sup)
  • Lists (ul, ol, li)
  • Images (img, figure, figcaption)
  • Tables (table, thead, tbody, tr, th, td)
  • Links (a)
  • MathML (mathematical expressions)
  • Collapsible sections (details, summary)
  • Semantic elements (article, section, blockquote, cite)

All content is sanitized to prevent XSS attacks while preserving safe HTML structure.


Theming

The package includes two built-in themes and supports custom themes via CSS variables. You are not required to use the built-in styles — you can fully customize the appearance to match your application's design system.

Required CSS Imports

Import the base theme CSS in your app's root layout or global styles:

// In your layout.tsx or globals.css
import "@superbuilders/incept-renderer/styles/themes.css"

Built-in Themes

Duolingo (Default)

Clean, friendly design inspired by Duolingo's learning interface with a vibrant color palette.

For the complete Duolingo experience, import the Duolingo theme CSS which includes:

  • Full Duolingo color palette (owl green, macaw blue, cardinal red, etc.)
  • Rounded square choice indicators (A, B, C, D)
  • Filled checkboxes with inset gap effect
  • HTML content styling (lists, typography)
  • Dark mode support
// In your layout.tsx or globals.css
import "@superbuilders/incept-renderer/styles/themes.css"
import "@superbuilders/incept-renderer/styles/duolingo.css"  // Full Duolingo styling
<QTIRenderer theme="duolingo" ... />

Neobrutalist

Bold, high-contrast design with thick borders and sharp shadows.

<QTIRenderer theme="neobrutalist" ... />

Custom Themes

Create your own theme by defining CSS variables for [data-qti-theme="your-theme-name"] in your stylesheet, then pass that theme name to the QTIRenderer component.

<QTIRenderer theme="my-custom-theme" ... />

CSS Variables Reference

Below is the complete list of CSS variables you can override when creating a custom theme. All variables are optional — any variable you don't define will fall back to the default value.

Container Variables

Controls the main question container/card appearance.

| Variable | Description | Default | |----------|-------------|---------| | --qti-container-bg | Background color of the container | var(--background, #ffffff) | | --qti-container-border | Border color of the container | var(--border, #e5e5e5) | | --qti-container-border-width | Border thickness | 2px | | --qti-container-radius | Border radius (rounded corners) | var(--radius, 0.5rem) | | --qti-container-shadow | Box shadow | none |

Typography Variables

Controls fonts and text styling throughout the renderer.

| Variable | Description | Default | |----------|-------------|---------| | --qti-font-family | Font family for all QTI text | inherit | | --qti-font-weight | Base font weight | 500 | | --qti-tracking | Letter spacing | normal |

Choice Variables

Controls the appearance of answer choices (radio buttons, checkboxes).

| Variable | Description | Default | |----------|-------------|---------| | --qti-choice-bg | Default background of choices | var(--background, #ffffff) | | --qti-choice-border | Default border color of choices | var(--accent, #f4f4f5) | | --qti-choice-hover-bg | Background on hover | var(--muted, #f4f4f5) | | --qti-choice-selected-bg | Background when selected | oklch(0.9 0.1 220 / 0.3) | | --qti-choice-selected-border | Border when selected | oklch(0.6 0.2 250) | | --qti-choice-correct-bg | Background for correct answers | oklch(0.9 0.15 140 / 0.3) | | --qti-choice-correct-border | Border for correct answers | #22c55e | | --qti-choice-incorrect-bg | Background for incorrect answers | oklch(0.9 0.1 25 / 0.3) | | --qti-choice-incorrect-border | Border for incorrect answers | #ef4444 |

Button Variables

Controls the appearance of buttons (if using the QTI button classes).

| Variable | Description | Default | |----------|-------------|---------| | --qti-button-bg | Button background color | var(--primary, #18181b) | | --qti-button-text | Button text color | var(--primary-foreground, #ffffff) | | --qti-button-border | Button border color | var(--primary, #18181b) | | --qti-button-shadow | Button box shadow | none | | --qti-button-radius | Button border radius | var(--radius, 0.5rem) | | --qti-button-font-weight | Button font weight | 700 |

Feedback Variables

Controls the appearance of feedback messages shown after validation.

| Variable | Description | Default | |----------|-------------|---------| | --qti-feedback-bg | Feedback container background | var(--background, #ffffff) | | --qti-feedback-border | Feedback container border | var(--border, #e5e5e5) | | --qti-feedback-correct-text | Text color for correct feedback | #22c55e | | --qti-feedback-correct-icon-bg | Icon background for correct | #22c55e | | --qti-feedback-incorrect-text | Text color for incorrect feedback | #ef4444 | | --qti-feedback-incorrect-icon-bg | Icon background for incorrect | #ef4444 |

Input Variables (Text Entry, Textarea, Dropdowns)

Controls the appearance of text input fields, textareas, and select/dropdown elements.

| Variable | Description | Default | |----------|-------------|---------| | --qti-input-bg | Input background color | var(--background, #ffffff) | | --qti-input-border | Input border color | var(--border, #e5e5e5) | | --qti-input-border-width | Input border thickness | 2px | | --qti-input-radius | Input border radius | var(--radius, 0.5rem) | | --qti-input-shadow | Input box shadow | none | | --qti-input-focus-border | Border color when focused | var(--ring, #2563eb) | | --qti-input-focus-shadow | Box shadow when focused | 0 0 0 2px rgba(37, 99, 235, 0.2) | | --qti-input-correct-border | Border for correct text answers | #22c55e | | --qti-input-incorrect-border | Border for incorrect text answers | #ef4444 |

Choice Indicator Variables (Radio/Checkbox Circles)

Controls the appearance of the radio button and checkbox indicators.

| Variable | Description | Default | |----------|-------------|---------| | --qti-indicator-size | Size of the indicator | 1.5rem | | --qti-indicator-bg | Default background | var(--background, #ffffff) | | --qti-indicator-border | Default border color | var(--border, #d4d4d8) | | --qti-indicator-border-width | Border thickness | 2px | | --qti-indicator-radius | Border radius (use 9999px for circles, 0 for squares) | 9999px | | --qti-indicator-checked-bg | Background when checked | var(--primary, #18181b) | | --qti-indicator-checked-border | Border when checked | var(--primary, #18181b) | | --qti-indicator-checked-text | Checkmark/dot color | var(--primary-foreground, #ffffff) |

Progress Bar Variables

Controls the appearance of progress indicators.

| Variable | Description | Default | |----------|-------------|---------| | --qti-progress-bg | Progress bar background (empty part) | var(--muted, #f4f4f5) | | --qti-progress-fill | Progress bar fill color | var(--primary, #18181b) | | --qti-progress-height | Height of the progress bar | 0.5rem | | --qti-progress-radius | Border radius | var(--radius, 0.5rem) | | --qti-progress-border | Border color | transparent | | --qti-progress-border-width | Border thickness | 0px |

Content Variables (Question Text, Prompts)

Controls the appearance of HTML content (question text, prompts, instructions).

| Variable | Description | Default | |----------|-------------|---------| | --qti-content-font-size | Font size for content | 1.125rem | | --qti-content-line-height | Line height for content | 1.625 | | --qti-content-font-weight | Font weight for content | 500 |

Image Grid Variables

Controls the layout of image-based choice interactions.

| Variable | Description | Default | |----------|-------------|---------| | --qti-grid-gap | Gap between grid items | 1rem | | --qti-image-card-min-height | Minimum height of image cards | 180px | | --qti-image-card-padding | Padding inside image cards | 1rem | | --qti-image-max-height | Maximum height of images | 160px |

Order Interaction Variables (Drag-and-Drop Sorting)

Controls the appearance of the order interaction container that holds draggable items.

| Variable | Description | Default | |----------|-------------|---------| | --qti-order-container-bg | Container background | var(--muted, #f4f4f5) | | --qti-order-container-border | Container border color | transparent | | --qti-order-container-border-width | Container border thickness | 2px | | --qti-order-container-radius | Container border radius | 0.75rem | | --qti-order-container-padding | Container padding | 1rem | | --qti-order-container-gap | Gap between drag items | 0.5rem | | --qti-order-container-correct-bg | Background when correct | oklch(0.9 0.15 140 / 0.2) | | --qti-order-container-correct-border | Border when correct | #22c55e | | --qti-order-container-incorrect-bg | Background when incorrect | oklch(0.9 0.1 25 / 0.2) | | --qti-order-container-incorrect-border | Border when incorrect | #ef4444 |

Order Item Variables (Drag Tokens)

Controls the appearance of individual draggable items/tokens.

| Variable | Description | Default | |----------|-------------|---------| | --qti-order-item-bg | Item background | var(--background, #ffffff) | | --qti-order-item-border | Item border color | var(--border, #e5e5e5) | | --qti-order-item-border-width | Item border thickness | 1px | | --qti-order-item-radius | Item border radius | 0.5rem | | --qti-order-item-padding | Item padding | 1rem | | --qti-order-item-shadow | Item box shadow | 0 1px 2px rgba(0, 0, 0, 0.05) | | --qti-order-item-hover-border | Border on hover | oklch(0.6 0.2 250) | | --qti-order-item-hover-shadow | Shadow on hover | 0 4px 6px rgba(0, 0, 0, 0.1) | | --qti-order-item-dragging-border | Border while dragging | oklch(0.6 0.2 250) | | --qti-order-item-dragging-shadow | Shadow while dragging | 0 8px 16px rgba(0, 0, 0, 0.15) | | --qti-order-item-handle-color | Drag handle icon color | var(--muted-foreground, #71717a) |

Complete Custom Theme Example

Here's a complete example defining all variables for a custom theme:

/* In your globals.css or styles file */
[data-qti-theme="my-custom-theme"] {
  /* ========== Container ========== */
  --qti-container-bg: #ffffff;
  --qti-container-border: #e0e0e0;
  --qti-container-border-width: 1px;
  --qti-container-radius: 12px;
  --qti-container-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);

  /* ========== Typography ========== */
  --qti-font-family: "Inter", system-ui, sans-serif;
  --qti-font-weight: 400;
  --qti-tracking: normal;

  /* ========== Choices (Radio/Checkbox Cards) ========== */
  --qti-choice-bg: #f8f8f8;
  --qti-choice-border: #d0d0d0;
  --qti-choice-hover-bg: #f0f0f0;
  --qti-choice-selected-bg: #e8f4fd;
  --qti-choice-selected-border: #2196f3;
  --qti-choice-correct-bg: #e8f5e9;
  --qti-choice-correct-border: #4caf50;
  --qti-choice-incorrect-bg: #ffebee;
  --qti-choice-incorrect-border: #f44336;

  /* ========== Buttons ========== */
  --qti-button-bg: #2196f3;
  --qti-button-text: #ffffff;
  --qti-button-border: transparent;
  --qti-button-shadow: none;
  --qti-button-radius: 8px;
  --qti-button-font-weight: 600;

  /* ========== Feedback Messages ========== */
  --qti-feedback-bg: #ffffff;
  --qti-feedback-border: #e0e0e0;
  --qti-feedback-correct-text: #2e7d32;
  --qti-feedback-correct-icon-bg: #4caf50;
  --qti-feedback-incorrect-text: #c62828;
  --qti-feedback-incorrect-icon-bg: #f44336;

  /* ========== Text Inputs / Textareas / Dropdowns ========== */
  --qti-input-bg: #ffffff;
  --qti-input-border: #d0d0d0;
  --qti-input-border-width: 1px;
  --qti-input-radius: 8px;
  --qti-input-shadow: none;
  --qti-input-focus-border: #2196f3;
  --qti-input-focus-shadow: 0 0 0 3px rgba(33, 150, 243, 0.2);
  --qti-input-correct-border: #4caf50;
  --qti-input-incorrect-border: #f44336;

  /* ========== Choice Indicators (Radio/Checkbox Circles) ========== */
  --qti-indicator-size: 1.5rem;
  --qti-indicator-bg: #ffffff;
  --qti-indicator-border: #d0d0d0;
  --qti-indicator-border-width: 2px;
  --qti-indicator-radius: 9999px; /* Use 0 for square checkboxes */
  --qti-indicator-checked-bg: #2196f3;
  --qti-indicator-checked-border: #2196f3;
  --qti-indicator-checked-text: #ffffff;

  /* ========== Progress Bar ========== */
  --qti-progress-bg: #e0e0e0;
  --qti-progress-fill: #2196f3;
  --qti-progress-height: 8px;
  --qti-progress-radius: 4px;
  --qti-progress-border: transparent;
  --qti-progress-border-width: 0;

  /* ========== Content (Question Text) ========== */
  --qti-content-font-size: 1.125rem;
  --qti-content-line-height: 1.6;
  --qti-content-font-weight: 400;

  /* ========== Image Grids ========== */
  --qti-grid-gap: 1rem;
  --qti-image-card-min-height: 180px;
  --qti-image-card-padding: 1rem;
  --qti-image-max-height: 160px;

  /* ========== Order Interaction (Drag-and-Drop Container) ========== */
  --qti-order-container-bg: #f5f5f5;
  --qti-order-container-border: #e0e0e0;
  --qti-order-container-border-width: 1px;
  --qti-order-container-radius: 12px;
  --qti-order-container-padding: 1rem;
  --qti-order-container-gap: 0.75rem;
  --qti-order-container-correct-bg: rgba(76, 175, 80, 0.15);
  --qti-order-container-correct-border: #4caf50;
  --qti-order-container-incorrect-bg: rgba(244, 67, 54, 0.15);
  --qti-order-container-incorrect-border: #f44336;

  /* ========== Order Items (Drag Tokens) ========== */
  --qti-order-item-bg: #ffffff;
  --qti-order-item-border: #e0e0e0;
  --qti-order-item-border-width: 1px;
  --qti-order-item-radius: 8px;
  --qti-order-item-padding: 1rem;
  --qti-order-item-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
  --qti-order-item-hover-border: #2196f3;
  --qti-order-item-hover-shadow: 0 4px 8px rgba(0, 0, 0, 0.12);
  --qti-order-item-dragging-border: #2196f3;
  --qti-order-item-dragging-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
  --qti-order-item-handle-color: #9e9e9e;
}

/* Optional: Dark mode support for your custom theme */
.dark[data-qti-theme="my-custom-theme"],
[data-qti-theme="my-custom-theme"] .dark {
  /* Container */
  --qti-container-bg: #1a1a1a;
  --qti-container-border: #333333;
  
  /* Choices */
  --qti-choice-bg: #252525;
  --qti-choice-border: #444444;
  --qti-choice-hover-bg: #333333;
  --qti-choice-selected-bg: rgba(33, 150, 243, 0.2);
  --qti-choice-selected-border: #2196f3;
  --qti-choice-correct-bg: rgba(76, 175, 80, 0.2);
  --qti-choice-incorrect-bg: rgba(244, 67, 54, 0.2);
  
  /* Feedback */
  --qti-feedback-bg: #1a1a1a;
  --qti-feedback-border: #333333;
  
  /* Inputs */
  --qti-input-bg: #252525;
  --qti-input-border: #444444;
  
  /* Indicators */
  --qti-indicator-bg: #252525;
  --qti-indicator-border: #444444;
  
  /* Progress */
  --qti-progress-bg: #333333;

  /* Order Interaction */
  --qti-order-container-bg: #252525;
  --qti-order-container-border: #444444;
  --qti-order-container-correct-bg: rgba(76, 175, 80, 0.2);
  --qti-order-container-incorrect-bg: rgba(244, 67, 54, 0.2);
  
  /* Order Items */
  --qti-order-item-bg: #1a1a1a;
  --qti-order-item-border: #444444;
  --qti-order-item-handle-color: #888888;
}

Then use your custom theme:

<QTIRenderer theme="my-custom-theme" ... />

Partial Overrides

You don't need to define all variables. You can create a theme that only overrides specific values:

/* Minimal custom theme - only changes colors */
[data-qti-theme="brand-colors"] {
  --qti-choice-selected-bg: #your-brand-color-light;
  --qti-choice-selected-border: #your-brand-color;
  --qti-button-bg: #your-brand-color;
}

Complete Examples

Example 1: Simple Quiz Page

// app/quiz/[id]/page.tsx
import * as React from "react"
import { buildDisplayModel, validateResponsesSecure } from "@superbuilders/incept-renderer/actions"
import { db } from "@/db"
import { questions } from "@/db/schema"
import { eq } from "drizzle-orm"
import { QuizClient } from "./client"

export default function QuizPage({ params }: { params: Promise<{ id: string }> }) {
  const displayPromise = params.then(async (p) => {
    const question = await db.query.questions.findFirst({
      where: eq(questions.id, p.id)
    })
    if (!question) throw new Error("Question not found")
    return buildDisplayModel(question.xml)
  })

  async function validate(responses: Record<string, string | string[]>) {
    "use server"
    const { id } = await params
    const question = await db.query.questions.findFirst({
      where: eq(questions.id, id)
    })
    if (!question) throw new Error("Question not found")
    return validateResponsesSecure(question.xml, responses)
  }

  return (
    <React.Suspense fallback={<LoadingSkeleton />}>
      <QuizClient displayPromise={displayPromise} onValidate={validate} />
    </React.Suspense>
  )
}

Example 2: Multi-Question Assessment

// app/assessment/[id]/page.tsx
import * as React from "react"
import { buildDisplayModel, validateResponsesSecure } from "@superbuilders/incept-renderer/actions"
import { AssessmentClient } from "./client"

export default function AssessmentPage({ params }: { params: Promise<{ id: string }> }) {
  // Fetch all questions for this assessment
  const questionsPromise = params.then(async (p) => {
    const assessment = await fetchAssessment(p.id)
    return Promise.all(
      assessment.questionIds.map(async (qId) => {
        const xml = await fetchQuestionXml(qId)
        const display = await buildDisplayModel(xml)
        return { id: qId, xml, ...display }
      })
    )
  })

  // Generic validator that works for any question
  async function validateQuestion(questionId: string, responses: Record<string, string | string[]>) {
    "use server"
    const xml = await fetchQuestionXml(questionId)
    return validateResponsesSecure(xml, responses)
  }

  return (
    <React.Suspense fallback={<div>Loading assessment...</div>}>
      <AssessmentClient 
        questionsPromise={questionsPromise} 
        onValidate={validateQuestion} 
      />
    </React.Suspense>
  )
}

Example 3: With Theming Toggle

// app/quiz/[id]/client.tsx
"use client"

import * as React from "react"
import { QTIRenderer } from "@superbuilders/incept-renderer"
import type { DisplayItem, FormShape, ValidateResult } from "@superbuilders/incept-renderer"

const THEMES = [
  { value: "duolingo", label: "Duolingo" },
  { value: "neobrutalist", label: "Neobrutalist" },
] as const

type Theme = typeof THEMES[number]["value"]

export function QuizClient({ displayPromise, onValidate }: {
  displayPromise: Promise<{ item: DisplayItem; shape: FormShape; itemKey: string }>
  onValidate: (r: Record<string, string | string[]>) => Promise<ValidateResult>
}) {
  const { item } = React.use(displayPromise)
  
  const [responses, setResponses] = React.useState<Record<string, string | string[]>>({})
  const [result, setResult] = React.useState<ValidateResult | undefined>()
  const [showFeedback, setShowFeedback] = React.useState(false)
  const [theme, setTheme] = React.useState<Theme>("duolingo")

  return (
    <div>
      {/* Theme Selector */}
      <div className="mb-4 flex justify-end">
        <select
          value={theme}
          onChange={(e) => setTheme(e.target.value as Theme)}
          className="border rounded px-2 py-1"
        >
          {THEMES.map((t) => (
            <option key={t.value} value={t.value}>{t.label}</option>
          ))}
        </select>
      </div>

      {/* Question Renderer */}
      <QTIRenderer
        item={item}
        responses={responses}
        onResponseChange={(id, val) => setResponses(prev => ({ ...prev, [id]: val }))}
        showFeedback={showFeedback}
        theme={theme}
        overallFeedback={result ? {
          isCorrect: result.overallCorrect,
          messageHtml: result.feedbackHtml
        } : undefined}
        choiceCorrectness={result?.selectedChoicesByResponse}
      />

      {/* Check Button */}
      <button
        onClick={async () => {
          const validation = await onValidate(responses)
          setResult(validation)
          setShowFeedback(true)
        }}
        disabled={showFeedback}
        className="mt-4 bg-blue-500 text-white px-4 py-2 rounded"
      >
        Check Answer
      </button>
    </div>
  )
}

Supported Interactions

| Interaction Type | Support | Description | |------------------|---------|-------------| | choiceInteraction | ✅ Full | Single/multiple choice questions | | inlineChoiceInteraction | ✅ Full | Dropdown selections within text | | textEntryInteraction | ✅ Full | Free-text input fields | | orderInteraction | 🔶 Basic | Drag-and-drop ordering | | matchInteraction | 🔶 Basic | Matching pairs | | gapMatchInteraction | 🔶 Basic | Fill-in-the-blank with draggable options |


FAQ

How do I store QTI XML?

Store it however works best for your app:

  • Database: Store as a TEXT/VARCHAR column
  • File system: Store as .xml files
  • External API: Fetch from a QTI content server

The package doesn't care where the XML comes from—it just needs a string.

Can users cheat by inspecting the browser?

No! Correct answers are never sent to the browser. The buildDisplayModel function only extracts display data. Validation happens on the server with the original XML.

How do I add custom styling?

Use the theme prop with a custom theme name, then define CSS variables for [data-qti-theme="your-theme"] in your stylesheet.

Can I use this without Next.js?

The package is designed for Next.js server components and server actions. For other frameworks, you'd need to:

  1. Create your own server endpoint for buildDisplayModel and validateResponsesSecure
  2. Call those endpoints from your client

How do I handle MathML/LaTeX?

The package preserves MathML in the HTML output. Ensure your app has MathML rendering support (modern browsers handle it natively, or use MathJax/KaTeX for broader support).


License

MIT © Superbuilders