@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
- Core Concepts
- Quick Start
- Architecture
- API Reference
- Rendering Stimuli
- Theming
- Complete Examples
- Supported Interactions
- FAQ
Installation
npm install @superbuilders/incept-renderer
# or
bun add @superbuilders/incept-renderer
# or
yarn add @superbuilders/incept-rendererPeer Dependencies
This package requires:
react>= 18.0.0react-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:
- Server parses XML → Extracts only display data (questions, choices, prompts)
- Client renders UI → Users interact with questions
- Server validates → Checks answers against the original XML
- 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 stateData 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 displayedAPI 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 (
h1–h6) - 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
.xmlfiles - 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:
- Create your own server endpoint for
buildDisplayModelandvalidateResponsesSecure - 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
