Escape
HomeTemplatesReceipt

Receipt template for JavaScript

A till receipt is 80 millimetres wide and as long as it needs to be. That sounds like a different problem from an A4 invoice, and it isn’t: this is the same kind of definition, rendered onto different paper. Nothing in the file below mentions 80 millimetres. The paper is the host’s to choose, so one definition prints to a roll, a sheet, or a screen.

The template

Everything the receipt says is in here, from the merchant block and the line table to the two VAT bands and the card line. The document sets itself in mono at 7 point in a single declaration at the top. That’s the way a receipt printer sets everything in one face. The table and footer below inherit it without repeating it.

receipt.report.json
{
  "data": "$.receipt.lines[*]",
  "aggregates": {
    "subtotal": "sum:[email protected] * @.unitPrice",
    "vatLow": "sum:[email protected] == 9 ? @.qty * @.unitPrice * 0.09 : 0",
    "vatHigh": "sum:[email protected] == 21 ? @.qty * @.unitPrice * 0.21 : 0"
  },
  "style": { "family": "mono", "size": 7 },
  "header": [
    { "type": "text", "value": "{{ $.input.receipt.merchant.name }}",
      "style": { "size": 11, "bold": true, "color": "#1e1f22", "align": "center" } },
    { "type": "text", "value": "{{ $.input.receipt.merchant.street }}",
      "style": { "size": 7, "color": "#5e6062", "align": "center" } },
    { "type": "text", "value": "{{ $.input.receipt.merchant.city }}",
      "style": { "size": 7, "color": "#5e6062", "align": "center" } },
    { "type": "text", "value": "VAT {{ $.input.receipt.merchant.vatId }}",
      "style": { "size": 6, "color": "#989a97", "align": "center" } },
    {
      "type": "split",
      "style": { "spaceBefore": 8, "spaceAfter": 8 },
      "slots": [
        { "type": "text", "value": "{{ $.input.receipt.number }}", "width": 50,
          "style": { "size": 7, "color": "#5e6062" } },
        { "type": "text", "value": [{ "value": "{{ $.input.receipt.issued }}" }, { "value": " {{ $.input.receipt.time }}" }],
          "style": { "size": 7, "color": "#5e6062", "align": "right", "format": "date" } }
      ]
    }
  ],
  "detail": {
    "header": {
      "style": { "size": 6, "uppercase": true, "color": "#989a97",
        "borderBottomWidth": 0.5, "borderBottomStyle": "solid", "borderBottomColor": "#1e1f22" }
    },
    "row": { "style": { "color": "#5e6062" } },
    "columns": [
      { "header": "Item", "value": "{{ @.item }}", "width": 32 },
      { "header": { "value": "Qty", "style": { "align": "right" } }, "value": "{{ @.qty }}", "width": 12,
        "style": { "align": "right" } },
      { "header": { "value": "Unit", "style": { "align": "right" } }, "value": "{{ @.unitPrice }}", "width": 20,
        "style": { "align": "right", "format": "currency" } },
      { "header": { "value": "VAT", "style": { "align": "right" } }, "value": "{{ @.vatRate / 100 }}", "width": 13,
        "style": { "align": "right", "color": "#989a97", "format": { "kind": "percent", "digits": 0 } } },
      { "header": { "value": "Amount", "style": { "align": "right" } }, "value": "{{ round(@.qty * @.unitPrice, 2) }}", "width": 23,
        "style": { "align": "right", "format": "currency" } }
    ],
    "total": {
      "style": { "color": "#989a97", "align": "right", "paddingTop": 0, "paddingBottom": 0 },
      "rows": [
        {
          "style": { "color": "#5e6062", "paddingTop": 7 },
          "cells": [
            { "value": "Subtotal", "span": 4 },
            { "value": "{{ round($.subtotal, 2) }}", "style": { "format": "currency" } }
          ]
        },
        {
          "visible": "=$.vatLow != 0",
          "cells": [
            { "value": "VAT 9%", "span": 4 },
            { "value": "{{ round($.vatLow, 2) }}", "style": { "format": "currency" } }
          ]
        },
        {
          "visible": "=$.vatHigh != 0",
          "cells": [
            { "value": "VAT 21%", "span": 4 },
            { "value": "{{ round($.vatHigh, 2) }}", "style": { "format": "currency" } }
          ]
        },
        {
          "style": { "size": 9, "bold": true, "color": "#1e1f22" },
          "cells": [
            { "value": "Total", "span": 4, "style": { "uppercase": true } },
            { "value": "{{ round($.subtotal + $.vatLow + $.vatHigh, 2) }}", "style": { "format": "currency" } }
          ]
        }
      ]
    }
  },
  "footer": [
    {
      "type": "split",
      "style": { "spaceBefore": 8 },
      "slots": [
        { "type": "text", "value": "{{ $.input.receipt.payment.method }} ••••{{ $.input.receipt.payment.last4 }}",
          "width": 60, "style": { "color": "#5e6062" } },
        { "type": "text", "value": "{{ round($.subtotal + $.vatLow + $.vatHigh, 2) }}",
          "style": { "color": "#5e6062", "align": "right", "format": "currency" } }
      ]
    },
    { "type": "text", "value": "Auth {{ $.input.receipt.payment.auth }}",
      "style": { "size": 6, "color": "#989a97" } },
    { "type": "text", "value": "Thank you",
      "style": { "size": 8, "color": "#1e1f22", "align": "center", "spaceBefore": 8 } },
    { "type": "text", "value": "{{ $.input.receipt.note }}",
      "style": { "size": 6, "color": "#989a97", "align": "center" } }
  ]
}

One detail is worth copying. Every item in the report header states its own size. That band carries a headline default — bold at 14 point — which sits above the document default. A header item that declares no size comes out headline-sized. Items in the table and the footer have no such default and simply inherit the 7 point set at the top.

Two things here that the invoice doesn’t do. The VAT bands are conditional aggregates. "sum:[email protected] == 9 ? @.qty * @.unitPrice * 0.09 : 0" sums only the rows that match. A receipt mixing 9% books with 21% printing totals each band separately, without grouping or a second pass over the data. And each band declares "visible": "=$.vatLow != 0", so a receipt with nothing in that band simply doesn’t print the line.

The total wraps its sum in round(…, 2). Subtotal plus the two bands computes to 83.75999999999999 for the sample basket — exact amounts, inexact sum. "format": "currency" presents that as €83.76 on the roll. The workbook stores the number the cell holds, so the definition rounds it first.

The data

This is the sample the preview renders. $.receipt.lines[*] selects the rows the table repeats over. $.input reads the merchant, the number, and the payment:

receipt.data.json
{
  "receipt": {
    "number": "R-2026-0884",
    "issued": "2026-08-21",
    "time": "14:32",
    "merchant": {
      "name": "Quire Press Ltd.",
      "street": "Binnenkant 42",
      "city": "1011 BM Amsterdam",
      "vatId": "NL8123.45.678.B01"
    },
    "lines": [
      { "item": "Marbled endpapers", "qty": 4, "unitPrice": 3.5, "vatRate": 21 },
      { "item": "Bookcloth, quarter", "qty": 1, "unitPrice": 18, "vatRate": 21 },
      { "item": "Proof copy, A4", "qty": 2, "unitPrice": 6, "vatRate": 21 },
      { "item": "Type specimen book", "qty": 1, "unitPrice": 28, "vatRate": 9 }
    ],
    "payment": { "method": "Maestro", "last4": "4417", "auth": "0043821" },
    "note": "Returns within 14 days with this receipt"
  }
}

The header line carries both as two styled runs. The date sits in a run of its own, which the line’s "format": "date" presents as 21 Aug 2026. The time sits in the run beside it, printed exactly as the data supplied it. That’s deliberate: a till knows the local time, and the receipt just prints what it receives.

What it renders

This isn’t a screenshot. quario compiles the definition and data above, and @quario/viewer renders them in your browser. That’s an 80 mm roll, because it’s what this page’s host asked for. The PDF button hands you the same receipt as real @quario/pdf bytes, one page, 226.8 × 453.5 points. The DOCX button beside it hands the same definition to @quario/docx, where Word paginates and resolves the faces itself:

Rendering live with @quario/viewer80 mm roll

Use it

shell
npm install quario @quario/html
app.js
import { quario } from 'quario'
import { html } from '@quario/html'
import receipt from './receipt.report.json' with { type: 'json' }

const q = quario({ locale: 'en-IE', currency: 'EUR' })   // what `format` presents through
const report = q.report(receipt)

const data = await getSale(id)                       // your data
const page = await report.render(html(), data)       // ready to display

The roll is a render option, not part of the document. size takes a [width, height] in points — 80 mm is 226.8 of them — and the margin comes down to what a receipt printer actually leaves:

pdf.js
import { writeFileSync } from 'node:fs'
import { pdf } from '@quario/pdf'

const bytes = await report.render(pdf({ page: { size: [226.8, 453.5], margin: 14 } }), data)

writeFileSync('receipt.pdf', bytes)

Swap that one object for { size: 'A4', margin: 54 } and the same definition prints a full-sheet receipt instead. See JSON to PDF for both PDF routes side by side.

Make it yours

The document says what belongs on the paper. The host says how big the paper is. So the edits you’d expect to be layout work are edits to data. Change the roll width for a 58 mm printer. Add a logo above the merchant name. Drop the VAT bands for a jurisdiction that doesn’t need them, and localise the labels. All of it diffs in a pull request, and the same definition renders identically in Node, the browser, and at the edge. It renders without eval, so a customer’s own line items never become code. Security →

FAQ

Can I print this to a thermal receipt printer?

The PDF route gives you an 80 mm page you can send to any printer that takes one. For an ESC/POS printer that wants raw commands rather than a page, render the same definition through your own target. The engine hands a target a stream of bands, items, and resolved styles, not markup.

Can I generate the receipt as a PDF?

Yes — @quario/pdf renders this definition straight to PDF bytes, no browser anywhere. The page geometry above is the only thing you change to move between a roll and a sheet. Full example →

Can I export the receipt to Excel?

Yes — @quario/xlsx renders the same definition to a workbook, and it simply ignores the roll geometry. A worksheet has no page to be 80 mm wide. Nothing to change if you do. The money columns declare "format": "currency" rather than baking a formatted string into the cell, so the amounts arrive as real numbers. Full example →

Can I use this receipt template commercially?

The definition is yours — the report definitions, data, and output you create with quario belong to you. quario is free to evaluate with no time limit. Taking real money at a real counter is past evaluation, so that needs a seat for each developer working with it. Deployment is then unlimited and royalty-free. Pricing →

What if a sale is only one VAT rate?

Then one band prints and the other doesn’t — that’s what visible on each band is for. Adding a third rate is one more aggregate and one more line. Nothing about the table changes.

Where are the other templates?

Browse the template gallery → — invoices, purchase orders, statements, and more.

Fold your data onto a till roll.

npm install quario
Getting started
© 2026 quario · KvK 61815977