JavaScript Framework for Psychology tasks. Make development like making PPTs.
Compared to others, it:
- Easier and more flexible
- Higher time precision. See benchmark
- Smaller bundle size, Faster loading speed. See benchmark
- Type-Safe
Integration with:
- jsPsych plugins.
- Data server: JATOS ...
- UI framework: Vue, Solid, Lit, Van ...
- Reactive framework: Rxjs, Mobx, Valtio ...
API Docs | Benchmark | Tests | Play it now ! 🥳
Note
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 componentsvia 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>Warning
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 doneOr, 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 optionsBlock:
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 fileIt a function that inputs Props and outputs an object includes Node and Data Getter:
- Props means the show parameters that control the display of the scene.
- Node is a string or element, or array, that is mounted to the scene root element.
- Data Getter is used to get generated data.
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')];Caution
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,
};
};Tip
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.
Note
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:
- Update props: merge patch props with default props to update current props, which will trigger reactivity update.
- Listeners added by
this.on('show')will be called. - Display and focus
this.root, it will be displayed on the screen in the next frame. - Create a timer by
this.options.timerand wait for it to stop. - Listeners added by
this.on('frame')will be called when the timer is running. - Hide
this.rootwhen the timer is stopped, it will be hidden on the screen in the next frame. - Listeners added by
this.on('close')will be called. - Merge the timer records and the data from Data Getter.
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 hintOr 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>Important
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);
});