jsPsych Experiment Example
A complete jsPsych experiment with behavioral capture and data export.
Project Structure
experiment/
├── index.html
├── src/
│ └── experiment.ts
├── package.json
├── tsconfig.json
└── vite.config.js
package.json
{
"name": "slopit-jspsych-experiment",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@slopit/adapter-jspsych": "^0.1.0",
"jspsych": "^8.0.0",
"@jspsych/plugin-html-keyboard-response": "^2.0.0",
"@jspsych/plugin-survey-text": "^2.0.0"
},
"devDependencies": {
"typescript": "^5.7.0",
"vite": "^6.0.0"
}
}
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"verbatimModuleSyntax": true,
"outDir": "dist",
"declaration": true
},
"include": ["src"]
}
vite.config.js
import { defineConfig } from "vite";
export default defineConfig({
build: {
outDir: "dist",
},
});
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Writing Study</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f0f0f0;
}
.jspsych-content {
max-width: 800px;
}
.jspsych-survey-text {
text-align: left;
}
.jspsych-survey-text textarea {
width: 100%;
padding: 12px;
font-size: 16px;
border: 2px solid #ccc;
border-radius: 4px;
resize: vertical;
box-sizing: border-box;
}
.jspsych-survey-text textarea:focus {
outline: none;
border-color: #0066cc;
}
.instructions {
background: white;
padding: 30px;
border-radius: 8px;
max-width: 600px;
margin: 0 auto;
}
.instructions h2 {
margin-top: 0;
}
.instructions ul {
text-align: left;
margin: 20px 0;
}
.instructions li {
margin-bottom: 10px;
}
</style>
</head>
<body>
<script type="module" src="/src/experiment.ts"></script>
</body>
</html>
src/experiment.ts
import { initJsPsych } from "jspsych";
import htmlKeyboardResponse from "@jspsych/plugin-html-keyboard-response";
import jsPsychSurveyText from "@jspsych/plugin-survey-text";
import { SlopitExtension, exportToSlopit } from "@slopit/adapter-jspsych";
// Configuration
const CONFIG = {
minResponseLength: 50,
maxResponseLength: 1000,
apiEndpoint: "/api/sessions",
// For Prolific, get from URL params
completionUrl: "https://app.prolific.co/submissions/complete?cc=COMPLETION_CODE",
};
// Get URL parameters
function getUrlParams(): Record<string, string> {
const params: Record<string, string> = {};
const searchParams = new URLSearchParams(window.location.search);
for (const [key, value] of searchParams) {
params[key] = value;
}
return params;
}
const urlParams = getUrlParams();
const participantId = urlParams["PROLIFIC_PID"] || urlParams["participant_id"] || "test-participant";
const studyId = urlParams["STUDY_ID"] || "writing-study-001";
const sessionId = urlParams["SESSION_ID"];
// Initialize jsPsych with SlopitExtension
const jsPsych = initJsPsych({
extensions: [{ type: SlopitExtension }],
on_finish: async () => {
// Show loading message
document.body.innerHTML = `
<div style="text-align: center; padding: 50px;">
<h2>Submitting your responses...</h2>
<p>Please wait.</p>
</div>
`;
// Export to slopit format
const session = exportToSlopit(jsPsych, {
participantId,
studyId,
trialFilter: (trial) => trial["slopit"] !== undefined,
});
// Add session ID from URL if available
if (sessionId) {
session.metadata = { ...session.metadata, prolificSessionId: sessionId };
}
try {
// Send to server
const response = await fetch(CONFIG.apiEndpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(session),
});
if (!response.ok) {
throw new Error(`Server responded with ${response.status}`);
}
// Redirect to completion
window.location.href = CONFIG.completionUrl;
} catch (error) {
console.error("Submission error:", error);
// Show error with manual completion option
document.body.innerHTML = `
<div style="text-align: center; padding: 50px;">
<h2>Submission Error</h2>
<p>We encountered an error submitting your responses.</p>
<p>Your data has been saved locally. Please copy the code below and return to Prolific:</p>
<p style="font-size: 1.5em; font-weight: bold; margin: 20px 0;">COMPLETION_CODE</p>
<p>If the problem persists, please contact the researcher.</p>
</div>
`;
// Also save data locally as backup
localStorage.setItem(`slopit-session-${session.sessionId}`, JSON.stringify(session));
}
},
});
// Writing prompts
const writingPrompts = [
{
id: "morning-routine",
prompt: `
<h3>Task 1 of 3: Morning Routine</h3>
<p>Describe your typical morning routine in detail. What do you do from the moment you wake up until you leave for work or start your day?</p>
`,
},
{
id: "favorite-meal",
prompt: `
<h3>Task 2 of 3: Cooking Instructions</h3>
<p>Explain how to prepare your favorite meal. Include the ingredients needed and step-by-step instructions.</p>
`,
},
{
id: "travel-memory",
prompt: `
<h3>Task 3 of 3: Travel Experience</h3>
<p>Describe a memorable travel experience. Where did you go? What made it memorable?</p>
`,
},
];
// Build timeline
const timeline: unknown[] = [];
// Welcome screen
timeline.push({
type: htmlKeyboardResponse,
stimulus: `
<div class="instructions">
<h2>Welcome to the Writing Study</h2>
<p>Thank you for participating in this research study.</p>
<p>In this study, you will complete <strong>3 short writing tasks</strong>.</p>
<ul>
<li>Each task asks you to write about a different topic</li>
<li>Please write at least ${CONFIG.minResponseLength} characters for each response</li>
<li>Write in your own words without copying from other sources</li>
<li>The study takes approximately 10-15 minutes</li>
</ul>
<p><strong>Press any key to continue.</strong></p>
</div>
`,
});
// Instructions screen
timeline.push({
type: htmlKeyboardResponse,
stimulus: `
<div class="instructions">
<h2>Before You Begin</h2>
<p>A few important notes:</p>
<ul>
<li>Please <strong>type your responses</strong> rather than copying and pasting</li>
<li>Try to stay focused on the task without switching to other tabs</li>
<li>There are no right or wrong answers; we are interested in your experiences</li>
</ul>
<p><strong>Press any key to begin the first task.</strong></p>
</div>
`,
});
// Writing tasks
for (const writingPrompt of writingPrompts) {
timeline.push({
type: jsPsychSurveyText,
questions: [
{
prompt: writingPrompt.prompt,
rows: 8,
columns: 60,
required: true,
},
],
button_label: "Submit Response",
extensions: [
{
type: SlopitExtension,
params: {
targetSelector: "textarea",
keystroke: {
enabled: true,
captureKeyUp: true,
includeModifiers: true,
},
focus: {
enabled: true,
useVisibilityAPI: true,
useBlurFocus: true,
},
paste: {
enabled: true,
prevent: true,
warnMessage: "Please type your response. Pasting is not allowed in this study.",
capturePreview: true,
},
},
},
],
data: {
task_id: writingPrompt.id,
},
});
}
// Debrief screen
timeline.push({
type: htmlKeyboardResponse,
stimulus: `
<div class="instructions">
<h2>Thank You!</h2>
<p>You have completed all writing tasks.</p>
<p>Your responses have been recorded and will be used for research purposes only.</p>
<p><strong>Press any key to submit your responses and complete the study.</strong></p>
</div>
`,
});
// Run experiment
jsPsych.run(timeline);
Running the Experiment
Development
npm install
npm run dev
Open http://localhost:5173 in your browser.
Production Build
npm run build
The built files are in the dist/ directory.
Key Features Demonstrated
1. SlopitExtension Registration
The extension is registered when initializing jsPsych:
const jsPsych = initJsPsych({
extensions: [{ type: SlopitExtension }],
});
2. Behavioral Capture Configuration
Each writing task enables the extension with specific configuration:
- Keystrokes (keydown and keyup events with modifier states)
- Focus events (tab switches and window blur)
- Paste events (blocked with warning message)
extensions: [
{
type: SlopitExtension,
params: {
targetSelector: "textarea",
keystroke: { enabled: true },
paste: { enabled: true, prevent: true },
},
},
],
3. Data Export
At experiment completion:
- Filters to only include trials with slopit data
- Includes participant ID from URL parameters
- Sends to server API endpoint
- Handles errors gracefully with local backup
4. Prolific Integration
- Reads PROLIFIC_PID, STUDY_ID, SESSION_ID from URL
- Redirects to Prolific completion URL
- Shows completion code if submission fails
Customization
Changing Writing Prompts
Edit the writingPrompts array.
const writingPrompts = [
{
id: "custom-task",
prompt: "<h3>Your Custom Task</h3><p>Instructions here...</p>",
},
];
Adjusting Capture Settings
Modify the extension params in each trial.
extensions: [
{
type: SlopitExtension,
params: {
targetSelector: "textarea",
keystroke: { enabled: true },
paste: { enabled: true, prevent: false }, // Allow paste
},
},
],
Using Different Plugins
The SlopitExtension works with any plugin that has text inputs:
import jsPsychSurveyHtmlForm from "@jspsych/plugin-survey-html-form";
{
type: jsPsychSurveyHtmlForm,
html: `<textarea id="response"></textarea>`,
extensions: [
{
type: SlopitExtension,
params: {
targetSelector: "#response",
keystroke: { enabled: true },
},
},
],
}
Different API Endpoint
const CONFIG = {
apiEndpoint: "https://your-server.com/api/sessions",
};
Server Requirements
Your server endpoint should:
- Accept POST requests with JSON body
- Validate the
SlopitSessionschema - Return 200 OK on success
- Store the session data for analysis
Example server validation using Zod:
import { SlopitSessionSchema } from "@slopit/core";
app.post("/api/sessions", (req, res) => {
const result = SlopitSessionSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ error: "Invalid session data" });
}
// Store session
await db.sessions.insert(result.data);
res.json({ success: true });
});
Testing Locally
Test without Prolific by visiting:
http://localhost:5173?participant_id=test123&STUDY_ID=dev
The experiment will use these as fallback identifiers.