JavaScript Framework for Psychology tasks. Make development like making PPTs.
Compared to others, it:
Integration with:
API Docs | Benchmark | Tests | Play it now ! 🥳
The project is in early development. Please pin the version when using it.
via NPM:
npm create psytask # optional: use template
npm install psytask # only package
npm install @psytask/component vanjs-core vanjs-ext # optional: use components
via CDN:
<!-- add required packages -->
<script type="importmap">
{
"imports": {
"psytask": "https://cdn.jsdelivr.net/npm/psytask@1/dist/index.min.js",
"@psytask/core": "https://cdn.jsdelivr.net/npm/@psytask/core@1/dist/index.min.js",
"@psytask/components": "https://cdn.jsdelivr.net/npm/@psytask/components@1/dist/index.min.js",
"vanjs-core": "https://cdn.jsdelivr.net/npm/vanjs-core@1.6",
"vanjs-ext": "https://cdn.jsdelivr.net/npm/vanjs-ext@0.6"
}
}
</script>
<!-- load packages -->
<script type="module">
import { createApp } from 'psytask';
using app = await creaeApp();
</script>
PsyTask uses the modern JavaScript using keyword for automatic resource cleanup.
For CDN usage in old browsers that don't support the using keyword, you will see Uncaught SyntaxError: Unexpected identifier 'app'. You need to change the code:
// Instead of: using app = await createApp();
const app = await createApp();
// ... your code ...
app.emit('dispose'); // Manually clean up when done
Or, you can use the bundlers (like Vite, Bun, etc.) to transpile it.
The psychology tasks are just like PPTs; they both have a series of scenes. So writing a task only requires 2 steps: creating and showing scenes.
All you need is Component:
import { Grating, adapter } from '@psytask/components';
using simpleText = app.scene(
// component
Grating,
// scene options
{
adapter, // VanJS support
defaultProps: { type: Math.sin, size: 100, sf: 0.02 }, // show params
duration: 1e3, // show 1000 ms
close_on: 'key: ', // close on space key
},
);
Override default props or options:
const data = await scene.show({ text: 'Press F or J' }); // new props
const data = await scene.config({ duration: 1e3 }).show(); // new options
Block:
import { RandomSampling, StairCase } from 'psytask';
// fixed sequence
for (const text of ['A', 'B', 'C']) {
await scene.show({ text });
}
// random sequence
for (const text of RandomSampling({
candidates: ['A', 'B', 'C'],
sample: 10,
replace: true,
})) {
await scene.show({ text });
}
// staircase
const staircase = StairCase({
start: 10,
step: 1,
up: 3,
down: 1,
reversals: 6,
min: 1,
max: 12,
trial: 20,
});
for (const value of staircase) {
const data = await scene.show({ text: value });
const correct = data.response_key === 'f';
staircase.response(correct); // set response
}
using dc = app.collector('data.csv');
for (const text of ['A', 'B', 'C']) {
const data = await scene.show({ text });
// `frame_times` will be recorded automatically
const start_time = /** @type {number} */ (data.frame_times[0]);
// add a row
dc.add({
text,
response: data.response_key,
rt: data.response_time - start_time,
correct: data.response_key === 'f',
});
}
dc.final(); // file content
dc.download(); // download file
It a function that inputs Props and outputs an object includes Node and Data Getter:
const Component = (props) => {
const ctx = getCurrentScene();
return { node: '', data: () => ({}) };
};
const Component = (props) => 'text node';
const Component = (props) => document.createElement('div');
const Component = (props) => ['text node', document.createElement('div')];
You shouldn't modify props, whatever, as it may change the default props. See one-way data flow in Redux and Vue.
A practical example:
import { on, getCurrentScene } from 'psytask';
import { ImageStim, adapter } from '@psytask/components';
import van from 'vanjs-core';
const { div } = van.tags;
const Component =
/** @param {{ text: string }} props */
(props) => {
/** @type {{ response_key: string; response_time: number }} */
let data;
const ctx = getCurrentScene();
// add DOM event listener
const cleanup = on(ctx.root, 'keydown', (e) => {
if (e.key !== 'f' || e.key !== 'j') return;
data = { response_key: e.key, response_time: e.timeStamp };
ctx.close(); // close on 'f' or 'j'
});
ctx
// reset data on show
.on('show', () => {
data = { response_key: '', response_time: 0 };
})
// remove DOM event listener on dispose
.on('dispose', cleanup);
// Return the element and data getter
return {
node: div(
// use other Component
ImageStim({ image: new ImageData(1) }),
),
data: () => data,
};
};
Use JSDoc Comment to get type hint in JavaScript.
When you call app.scene(Component, { adapter, defaultProps }), it will use adapter.render to call Component with defaultProps once, then Node will be mounted to this.root.
The component will be called only once; the following DOM update will be triggered by the Props update. See reactivity.
When you call await scene.show(patchProps), it will execute the following process:
this.on('show') will be called.this.root, it will be displayed on the screen in the next frame.this.options.timer and wait for it to stop.this.on('frame') will be called when the timer is running.this.root when the timer is stopped, it will be hidden on the screen in the next frame.this.on('close') will be called.graph LR
a[update props] --> l1[on show] --> b[display & focus DOM] --> d[wait timer] --> l2[on frame] --> d --> e[hide root] --> l3[on close] --> f[merge data]
Stay tuned...
Better to see: VanJS tutorial, Vue reactivity
The bundle size of PsyTask is 1/12 of labjs, 1/50 of jspsych, and 1/260 of psychojs.
xychart
title "Bundle Size (KB)"
x-axis [psytask, labjs, jspsych, psychojs]
y-axis 0 --> 2600
bar [10.67, 122.45, 502.06, 2598.33]
npm i @psytask/jspsych @jspsych/plugin-cloze
npm i -d jspsych # optional: for type hint
Or using CDN:
<!-- load jspsych css-->
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/jspsych@8.2.2/css/jspsych.css"
/>
<!-- add packages -->
<script type="importmap">
{
"imports": {
...
"@psytask/jspsych": "https://cdn.jsdelivr.net/npm/@psytask/jspsych@1/dist/index.min.js",
"@jspsych/plugin-cloze": "https://cdn.jsdelivr.net/npm/@jspsych/plugin-cloze@2.2.0/+esm"
}
}
</script>
For CDNer, you should add the +esm after the jspsych plugin CDN URL, because jspsych plugins do not release ESM versions. Or you can use esm.sh.
import { jsPsychStim } from '@psytask/jspsych';
import Cloze from '@jspsych/plugin-cloze';
using jspsych = app.scene(jsPsychStim, {
defaultProps: {
type: Cloze,
text: 'aba%%aba',
check_answers: true,
},
});
const data = await jspsych.show();
<!-- add jatos script -->
<script src="jatos.js"></script>
// wait for jatos loading
await new Promise((r) => jatos.onLoad(r));
using dc = app.collector().on('add', (row) => {
// send data to JATOS server
jatos.appendResultData(row);
});