Payslip template for JavaScript
A payslip restates one employee’s earnings, deductions, and net pay for a period. It demands no new money: it reports a payment already made. This one is a dense A4, two amount columns, a holiday accrual that sits outside the net.
The template
The table repeats over wage components. This period and year-to-date are columns on each row. Net pay is an aggregate over those rows, so the preview can’t drift from the lines above it. The company reserves holiday allowance this month rather than paying it, so it lives under the net rather than in the sum:
{
"data": "$.payslip.lines[*]",
"aggregates": {
"net": "sum:[email protected]",
"netYtd": "sum:[email protected]"
},
"style": { "family": "sans", "size": 10 },
"header": [
{
"type": "split",
"slots": [
{ "type": "text", "value": "Payslip", "width": 50,
"style": { "family": "serif", "size": 19, "bold": true, "color": "#1e1f22" } },
{ "type": "text", "value": [{ "value": "{{ period($.input.payslip.period) }} · Paid " }, { "value": "{{ $.input.payslip.paid }}" }],
"style": { "size": 8, "color": "#989a97", "align": "right", "format": "date" } }
]
},
{
"type": "split",
"style": { "spaceBefore": 34 },
"slots": [
{ "type": "text", "value": "Employer", "width": 50,
"style": { "family": "mono", "size": 7, "uppercase": true, "color": "#989a97" } },
{ "type": "text", "value": "Employee",
"style": { "family": "mono", "size": 7, "uppercase": true, "color": "#989a97" } }
]
},
{
"type": "split",
"slots": [
{ "type": "text", "value": "{{ $.input.payslip.employer.name }}", "width": 50,
"style": { "bold": true, "color": "#1e1f22" } },
{ "type": "text", "value": "{{ $.input.payslip.employee.name }}",
"style": { "bold": true, "color": "#1e1f22" } }
]
},
{
"type": "split",
"slots": [
{ "type": "text", "value": "{{ $.input.payslip.employer.street }}", "width": 50,
"style": { "color": "#5e6062" } },
{ "type": "text", "value": "No. {{ $.input.payslip.employee.number }}",
"style": { "color": "#5e6062" } }
]
},
{
"type": "split",
"style": { "spaceAfter": 40 },
"slots": [
{ "type": "text", "value": "{{ $.input.payslip.employer.city }}", "width": 50,
"style": { "color": "#5e6062" } },
{ "type": "text", "value": "{{ $.input.payslip.employee.role }}",
"style": { "color": "#5e6062" } }
]
}
],
"detail": {
"header": {
"style": { "family": "mono", "size": 7, "uppercase": true, "color": "#989a97",
"borderBottomWidth": 0.5, "borderBottomStyle": "solid", "borderBottomColor": "#1e1f22" }
},
"row": { "style": { "color": "#5e6062" } },
"columns": [
{ "header": "Description", "value": "{{ @.label }}", "width": 50 },
{ "header": { "value": "This period", "style": { "align": "right" } }, "value": "{{ @.amount }}", "width": 25,
"style": { "align": "right", "format": "currency" } },
{ "header": { "value": "Year to date", "style": { "align": "right" } }, "value": "{{ @.ytd }}", "width": 25,
"style": { "align": "right", "format": "currency" } }
],
"total": {
"style": { "paddingBottom": 0 },
"rows": [
{
"style": { "bold": true, "color": "#1e1f22", "paddingTop": 5,
"borderTopWidth": 0.5, "borderTopStyle": "solid", "borderTopColor": "#1e1f22" },
"cells": [
{ "value": "Net pay" },
{ "value": "{{ round($.net, 2) }}", "style": { "align": "right", "format": "currency" } },
{ "value": "{{ round($.netYtd, 2) }}", "style": { "align": "right", "format": "currency" } }
]
},
{
"style": { "size": 8, "color": "#5e6062", "paddingTop": 0 },
"cells": [
{ "value": "Holiday allowance reserved" },
{ "value": "{{ $.input.payslip.holiday.amount }}", "style": { "align": "right", "format": "currency" } },
{ "value": "{{ $.input.payslip.holiday.ytd }}", "style": { "align": "right", "format": "currency" } }
]
}
]
}
}
}Net pay and the holiday line are detail.total rows. They land in the same three columns the lines above use rather than in a block aligned by hand. A definition declares amounts rather than formatting them. The cell interpolates the bare number and "format": "currency" says how to show it, from the locale and currency you hand quario(). The paid date works the same way. The heading line is two styled runs. Its "format": "date" presents the run holding the date as 31 Aug 2026, leaving the period beside it alone. period() stays a registered function, because there is no kind for it: date would print a day on a heading that’s a month.
The data
This is the sample the preview below renders. $.payslip.lines[*] selects the rows the table repeats over. Year-to-date amounts are in the file: one month of data can’t total a year.
{
"payslip": {
"period": "2026-08",
"paid": "2026-08-31",
"employer": {
"name": "Quire Press Ltd.",
"street": "Binnenkant 42",
"city": "1011 BM Amsterdam"
},
"employee": {
"name": "S. Vink",
"number": "10417",
"role": "Compositor"
},
"lines": [
{ "label": "Gross pay", "amount": 3200, "ytd": 25600 },
{ "label": "Wage tax", "amount": -890, "ytd": -7120 }
],
"holiday": {
"amount": 256,
"ytd": 2048
}
}
}Wage tax is a negative amount so the net aggregate is a sum. Holiday allowance sits under that net.
What it renders
This isn’t a screenshot. quario compiles the definition and data above, and @quario/viewer renders
them in your browser, right here. The PDF button hands you the same payslip as real @quario/pdf
bytes, one A4 page, no browser rendering involved. The DOCX button beside it hands the same
definition to @quario/docx, where Word paginates and resolves the faces itself:
Use it
npm install quario @quario/htmlimport { quario } from 'quario'
import { html } from '@quario/html'
import payslip from './payslip.report.json' with { type: 'json' }
const q = quario({ locale: 'en-IE', currency: 'EUR' }) // what `format` presents through
const periodFmt = new Intl.DateTimeFormat('en-GB', { month: 'long', year: 'numeric' })
const report = q.report(payslip, {
period: (ym) => {
const [year, month] = ym.split('-').map(Number)
return periodFmt.format(new Date(year, month - 1, 1))
},
})
const data = await getPayslip(id) // your data
const page = await report.render(html(), data) // ready to display or printShow page in the browser, or render the same compiled report straight to a PDF to file with payroll
— no browser involved:
import { writeFileSync } from 'node:fs'
import { pdf } from '@quario/pdf'
const bytes = await report.render(pdf({ page: { size: 'A4', margin: 54 } }), data)
writeFileSync('payslip.pdf', bytes)See JSON to PDF for both PDF routes side by side.
Make it yours
A month with overtime is one more line in the data, plus "visible": "[email protected] != 0" on that row. That’s the way the purchase order hides shipTo when it’s missing. A pension contribution is another deduction line in the sum. All of it diffs in a pull request, and the same file renders identically in Node, the browser, and at the edge. It renders without eval, so employee names in the data never become code.
Security →
FAQ
Can I generate the payslip as a PDF?
Yes, and it’s usually the format you file: @quario/pdf renders this definition straight to bytes, with no browser anywhere in the path. That matters when payroll runs as a job rather than from a screen. Full example →
Can I export the payslip to Excel?
Yes. @quario/xlsx renders this same definition to a workbook, with nothing to change. The amount columns declare "format": "currency" rather than baking a formatted string into the cell, so each one arrives as a real number.
Full example →
Can I use this payslip template commercially?
The report definitions, data, and output you create with quario belong to you. quario is free to evaluate with no time limit. Paying staff from this output is past evaluation, so that needs a seat for each developer working with it. Deployment is then unlimited and royalty-free. Pricing →
Can one definition cover every employee?
Yes. The definition is the slip. Each render is one data document. A second employee is another JSON file, not another report.
Where are the other templates?
Browse the template gallery → — invoices, quotes, purchase orders, and more.