Skip to content

Receipt Pricing

Pricing section as till receipts for studio retainers: pick one and the itemised order, VAT and barcode feed out line by line.

  • Pricing
  • Design agency
  • React only · 0 deps
  • 1 file
Full screen

Browser support · All evergreen browsers. CSS masks for the torn edge (Safari via -webkit-mask).

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/receipt-pricing.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/pricing/receipt-pricing.tsx
1/**
2 * ReceiptPricing, agency / studio retainers rendered as printed till
3 * receipts hanging from a printer slot. Each receipt carries a summary; pick
4 * one and it "prints": the itemised order (hours, roles, VAT, total, barcode)
5 * feeds out of the slot line by line with a stepped animation while the slot
6 * light blinks. Perforated tear edges are a CSS mask; the barcode is an SVG
7 * generated from the order number.
8 *
9 * Props: packages ({ id, kind, name, price, per, summary[], items[], terms }),
10 * studio ({ name, address, vatId }), vatRate, currency, date, defaultId,
11 * title, lede.
12 * Item: [qty, description, amount|null].
13 * CSS vars: --fd-rcp-desk, --fd-rcp-paper, --fd-rcp-ink, --fd-rcp-accent.
14 *
15 * Browser support: CSS masks (all evergreen browsers, -webkit- prefixed for
16 * Safari); the feed animates a measured height.
17 * Accessibility: each receipt is an <article>; the print control is a
18 * <button aria-pressed> and the printed section is aria-hidden until printed.
19 * The barcode is decorative (the order number is printed as text).
20 * Reduced motion: the receipt appears printed instantly.
21 */
22import { useId, useLayoutEffect, useRef, useState } from "react";
23import type { ReactNode } from "react";
24
25export type ReceiptItem = [string, string, number | null];
26export interface ReceiptPackage {
27 id: string;
28 kind: string;
29 name: string;
30 price: number;
31 per: string;
32 summary: string[];
33 items: ReceiptItem[];
34 terms: string;
35 order: string;
36}
37
38export interface ReceiptPricingProps {
39 packages?: ReceiptPackage[];
40 studio?: { name: string; address: string; vatId: string };
41 vatRate?: number;
42 currency?: string;
43 date?: string;
44 defaultId?: string;
45 title?: string;
46 lede?: string;
47 className?: string;
48}
49
50const PACKAGES: ReceiptPackage[] = [
51 {
52 id: "sprint",
53 kind: "One-off",
54 name: "Brand sprint",
55 price: 7400,
56 per: "once",
57 order: "OO-2611-0412",
58 summary: ["2 weeks, start any Monday", "Logo, type, colour, 3 key layouts", "One round of changes"],
59 items: [
60 ["1", "Kick-off workshop, half day", 0],
61 ["60h", "Senior designer", 5700],
62 ["12h", "Design direction (Mara)", 1440],
63 ["1", "Figma library + handoff call", 260],
64 ["1", "Coffee, during workshop", null],
65 ],
66 terms: "50% on booking, 50% on delivery.",
67 },
68 {
69 id: "retainer-s",
70 kind: "Monthly",
71 name: "Retainer S",
72 price: 4800,
73 per: "month",
74 order: "OO-2611-0417",
75 summary: ["32 hours a month", "Weekly crit, Thursdays 10:00", "Replies within one working day"],
76 items: [
77 ["32h", "Senior designer", 4100],
78 ["4h", "Design direction", 480],
79 ["4", "Weekly crit, 45 min", 220],
80 ["8h", "Rollover cap, unused hours", null],
81 ["1", "Shared Slack channel", null],
82 ],
83 terms: "Cancel with 30 days' notice.",
84 },
85 {
86 id: "retainer-l",
87 kind: "Monthly",
88 name: "Retainer L",
89 price: 11200,
90 per: "month",
91 order: "OO-2611-0421",
92 summary: ["80 hours, two designers", "Same-day replies, 09:00 to 18:00", "Quarterly brand audit"],
93 items: [
94 ["80h", "Two senior designers", 8800],
95 ["12h", "Design direction", 1440],
96 ["1", "Quarterly brand audit", 960],
97 ["16h", "Rollover cap, unused hours", null],
98 ["1", "Motion and 3D, on request", null],
99 ],
100 terms: "3-month minimum, then 30 days' notice.",
101 },
102];
103
104const eur = (n: number, c: string) => c + n.toLocaleString("nl-NL", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
105
106function Barcode({ code }: { code: string }) {
107 let h = 2166136261;
108 const bars: { x: number; w: number }[] = [];
109 let x = 0;
110 const seq = "11010010000" + code.split("").map((ch) => {
111 h = Math.imul(h ^ ch.charCodeAt(0), 16777619) >>> 0;
112 return (h & 0x7ff).toString(2).padStart(11, "0");
113 }).join("") + "1100011101011";
114 for (let i = 0; i < seq.length; ) {
115 const bit = seq[i];
116 let run = 0;
117 while (i < seq.length && seq[i] === bit && run < 4) {
118 run++;
119 i++;
120 }
121 if (bit === "1") bars.push({ x, w: run });
122 x += run;
123 }
124 return (
125 <svg className="fd-rcp-bar" viewBox={`0 0 ${x} 40`} preserveAspectRatio="none" aria-hidden="true">
126 {bars.map((b, i) => (
127 <rect key={i} x={b.x} y="0" width={b.w} height="40" fill="currentColor" />
128 ))}
129 </svg>
130 );
131}
132
133function Feed({ open, children }: { open: boolean; children: ReactNode }) {
134 const outer = useRef<HTMLDivElement>(null);
135 const inner = useRef<HTMLDivElement>(null);
136 const first = useRef(true);
137 useLayoutEffect(() => {
138 const o = outer.current;
139 const i = inner.current;
140 if (!o || !i) return;
141 if (first.current || matchMedia("(prefers-reduced-motion: reduce)").matches) {
142 first.current = false;
143 o.style.height = "";
144 return;
145 }
146 const from = open ? 0 : o.offsetHeight;
147 const to = open ? i.offsetHeight : 0;
148 o.style.height = from + "px";
149 void o.offsetHeight;
150 o.style.height = to + "px";
151 const end = (e: TransitionEvent) => {
152 if (e.target === o) o.style.height = "";
153 };
154 o.addEventListener("transitionend", end);
155 return () => o.removeEventListener("transitionend", end);
156 }, [open]);
157 return (
158 <div className="fd-rcp-feed" ref={outer} data-open={open ? "" : undefined} aria-hidden={!open}>
159 <div className="fd-rcp-feed-in" ref={inner}>
160 {children}
161 </div>
162 </div>
163 );
164}
165
166export function ReceiptPricing({
167 packages = PACKAGES,
168 studio = { name: "Office Ordinary", address: "Witte de Withstraat 61, 3012 BN Rotterdam", vatId: "BTW NL8612.44.519.B01" },
169 vatRate = 0.21,
170 currency = "€",
171 date = "02-11-2026 10:41",
172 defaultId = "retainer-s",
173 title = "Design, by the hour, itemised.",
174 lede = "Three ways to hire us. Pick one and we print exactly what you get: hours, people, rituals and the bill. Nothing hidden under the fold.",
175 className,
176}: ReceiptPricingProps) {
177 const [sel, setSel] = useState(defaultId);
178 const [run, setRun] = useState(0);
179 const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
180
181 return (
182 <section className={`fd-rcp${className ? " " + className : ""}`} aria-labelledby={`${uid}-t`}>
183 <style>{css}</style>
184 <div className="fd-rcp-head">
185 <h2 className="fd-rcp-title" id={`${uid}-t`}>{title}</h2>
186 <p className="fd-rcp-lede">{lede}</p>
187 </div>
188 <div className="fd-rcp-row">
189 {packages.map((p, i) => {
190 const on = p.id === sel;
191 const vat = Math.round(p.price * vatRate * 100) / 100;
192 return (
193 <article key={p.id} className="fd-rcp-r" data-on={on ? "" : undefined} aria-label={`${p.name}, ${eur(p.price, currency)} per ${p.per}`}>
194 <div className="fd-rcp-slot" aria-hidden="true">
195 <span className="fd-rcp-led" key={on ? `on${run}` : "off"} />
196 <span className="fd-rcp-mouth" />
197 </div>
198 <div className="fd-rcp-paper">
199 <p className="fd-rcp-studio">{studio.name}</p>
200 <p className="fd-rcp-addr">{studio.address}</p>
201 <p className="fd-rcp-rule" aria-hidden="true">{"* ".repeat(40)}</p>
202 <p className="fd-rcp-kind">
203 <span>No. {String(i + 1).padStart(2, "0")}</span>
204 <span>{p.kind}</span>
205 </p>
206 <h3 className="fd-rcp-name">{p.name}</h3>
207 <p className="fd-rcp-price">
208 {eur(p.price, currency).replace(/,00$/, "")}
209 <small>/ {p.per}</small>
210 </p>
211 <ul className="fd-rcp-sum">
212 {p.summary.map((s) => (
213 <li key={s}>{s}</li>
214 ))}
215 </ul>
216
217 <Feed open={on}>
218 <p className="fd-rcp-meta">
219 <span>{date}</span>
220 <span>{p.order}</span>
221 </p>
222 <table className="fd-rcp-tab">
223 <tbody>
224 {p.items.map(([q, d, a]) => (
225 <tr key={d}>
226 <td>{q}</td>
227 <td>{d}</td>
228 <td>{a === null ? "incl." : a === 0 ? "free" : eur(a, "")}</td>
229 </tr>
230 ))}
231 </tbody>
232 </table>
233 <dl className="fd-rcp-totals">
234 <div>
235 <dt>Subtotal</dt>
236 <dd>{eur(p.price, "")}</dd>
237 </div>
238 <div>
239 <dt>BTW {Math.round(vatRate * 100)}%</dt>
240 <dd>{eur(vat, "")}</dd>
241 </div>
242 <div className="fd-rcp-grand">
243 <dt>Total {currency}</dt>
244 <dd>{eur(p.price + vat, "")}</dd>
245 </div>
246 </dl>
247 <p className="fd-rcp-terms">{p.terms}</p>
248 <Barcode code={p.order} />
249 <p className="fd-rcp-code">{p.order.split("").join(" ")}</p>
250 <a className="fd-rcp-cta" href={`#book-${p.id}`} tabIndex={on ? 0 : -1}>
251 Book a 30-min intro
252 </a>
253 <p className="fd-rcp-thanks">{studio.vatId}<br />Thank you. Keep this for your records.</p>
254 </Feed>
255
256 {!on && (
257 <button
258 type="button"
259 className="fd-rcp-print"
260 aria-pressed={false}
261 onClick={() => {
262 setSel(p.id);
263 setRun((r) => r + 1);
264 }}
265 >
266 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
267 <path d="M4 6V2h8v4M4 12H2.5V6.5h11V12H12M4 10h8v4H4z" stroke="currentColor" strokeWidth="1.3" strokeLinejoin="round" />
268 </svg>
269 Print this one
270 </button>
271 )}
272 {on && <span className="fd-rcp-stamp" aria-hidden="true">Selected</span>}
273 </div>
274 </article>
275 );
276 })}
277 </div>
278 <p className="fd-rcp-foot">Prices excl. BTW. Billed on the 1st, payment within 14 days. We take on four retainer clients at a time; two slots open for January.</p>
279 </section>
280 );
281}
282
283const css = `
284:where(.fd-rcp,.fd-rcp *){box-sizing:border-box;margin:0;padding:0;border:0;background:none;font:inherit;letter-spacing:inherit;color:inherit;text-decoration:none;list-style:none;border-collapse:collapse;border-spacing:0}
285:where(.fd-rcp,.fd-rcp *)::before,:where(.fd-rcp,.fd-rcp *)::after{box-sizing:border-box}
286:where(.fd-rcp svg){display:block;flex-shrink:0}
287:where(.fd-rcp button){appearance:none;-webkit-appearance:none;cursor:pointer}
288.fd-rcp{
289 --fd-ease-out:cubic-bezier(.22,1,.36,1);--fd-ease-in-out:cubic-bezier(.65,0,.35,1);--fd-spring:cubic-bezier(.34,1.56,.64,1);
290 --fd-dur-1:120ms;--fd-dur-2:240ms;--fd-dur-3:420ms;--fd-dur-4:700ms;
291 --fd-rcp-desk:#DADFD6;--fd-rcp-paper:#FDFCF7;--fd-rcp-ink:#1B1B19;--fd-rcp-accent:#F0466A;
292 --mut:#6F6E68;
293 --mono:ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;
294 display:block;width:100%;background:var(--fd-rcp-desk);color:var(--fd-rcp-ink);font:400 13px/20px var(--mono);-webkit-font-smoothing:antialiased;
295 padding:clamp(48px,7vw,96px) clamp(16px,4vw,56px)}
296.fd-rcp-head{max-width:1140px;margin:0 auto;display:grid;grid-template-columns:minmax(0,1.2fr) minmax(0,1fr);gap:24px 64px;align-items:end}
297.fd-rcp-title{font:700 clamp(2.25rem,5vw,4rem)/1 var(--mono);letter-spacing:-.06em}
298.fd-rcp-lede{max-width:44ch;font-size:.875rem;line-height:22px;color:#4C4B46}
299.fd-rcp-row{max-width:1140px;margin:56px auto 0;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:clamp(16px,3vw,40px);align-items:start}
300.fd-rcp-r{position:relative;display:flex;flex-direction:column;align-items:center;min-width:0}
301.fd-rcp-slot{position:relative;z-index:2;width:100%;height:28px;border-radius:10px;background:#1E1E1C;box-shadow:inset 0 1px 0 rgba(255,255,255,.08),0 6px 14px -8px rgba(0,0,0,.6)}
302.fd-rcp-mouth{position:absolute;left:14px;right:14px;bottom:7px;height:4px;border-radius:2px;background:#000}
303.fd-rcp-led{position:absolute;right:12px;top:6px;width:5px;height:5px;border-radius:50%;background:#3E3E3A}
304.fd-rcp-r[data-on] .fd-rcp-led{background:#5BE37D;box-shadow:0 0 6px #5BE37D;animation:fd-rcp-blink 160ms steps(1) 8}
305@keyframes fd-rcp-blink{50%{background:#3E3E3A;box-shadow:none}}
306.fd-rcp-paper{position:relative;width:calc(100% - 28px);margin-top:-12px;padding:28px 20px 30px;background:var(--fd-rcp-paper);
307 background-image:linear-gradient(rgba(0,0,0,.05),transparent 22px);
308 -webkit-mask:conic-gradient(from -45deg at bottom,#0000,#000 1deg 89deg,#0000 90deg) bottom/12px 7px repeat-x,linear-gradient(#000 0 0) top/100% calc(100% - 6.9px) no-repeat;
309 mask:conic-gradient(from -45deg at bottom,#0000,#000 1deg 89deg,#0000 90deg) bottom/12px 7px repeat-x,linear-gradient(#000 0 0) top/100% calc(100% - 6.9px) no-repeat;
310 filter:drop-shadow(0 10px 12px rgba(40,44,36,.18))}
311.fd-rcp-r[data-on] .fd-rcp-paper{animation:fd-rcp-jolt var(--fd-dur-3) steps(4) 1}
312@keyframes fd-rcp-jolt{0%{transform:translateY(-10px)}100%{transform:none}}
313.fd-rcp-studio{text-align:center;font:700 15px/20px var(--mono);letter-spacing:.18em;text-transform:uppercase}
314.fd-rcp-addr{margin-top:2px;text-align:center;font-size:10px;line-height:14px;color:var(--mut)}
315.fd-rcp-rule{margin:14px 0 12px;overflow:hidden;white-space:nowrap;font-size:11px;line-height:12px;color:var(--mut)}
316.fd-rcp-kind{display:flex;justify-content:space-between;font-size:11px;line-height:16px;letter-spacing:.08em;text-transform:uppercase;color:var(--mut)}
317.fd-rcp-name{margin-top:10px;font:700 1.5rem/28px var(--mono);letter-spacing:-.04em}
318.fd-rcp-price{display:flex;align-items:baseline;gap:8px;margin-top:6px;font:700 2.25rem/44px var(--mono);letter-spacing:-.06em;font-variant-numeric:tabular-nums}
319.fd-rcp-price small{font-size:12px;font-weight:400;letter-spacing:0;color:var(--mut)}
320.fd-rcp-sum{margin-top:12px;padding-top:12px;border-top:1px dashed rgba(27,27,25,.35)}
321.fd-rcp-sum li{position:relative;padding-left:16px;font-size:12px;line-height:20px}
322.fd-rcp-sum li::before{content:">";position:absolute;left:0;color:var(--mut)}
323.fd-rcp-feed{height:0;overflow:hidden;transition:height 1100ms steps(16,end)}
324.fd-rcp-feed[data-open]{height:auto}
325.fd-rcp-feed-in{display:flow-root}
326.fd-rcp-meta{display:flex;justify-content:space-between;margin-top:16px;padding-top:12px;border-top:1px dashed rgba(27,27,25,.35);font-size:10px;line-height:16px;color:var(--mut);white-space:pre}
327.fd-rcp-tab{width:100%;margin-top:8px;font-size:11px;line-height:16px;font-variant-numeric:tabular-nums}
328.fd-rcp-tab td{padding:3px 0;vertical-align:top}
329.fd-rcp-tab td:first-child{width:36px;color:var(--mut)}
330.fd-rcp-tab td:last-child{text-align:right;white-space:nowrap;padding-left:8px}
331.fd-rcp-totals{margin-top:8px;padding-top:8px;border-top:1px dashed rgba(27,27,25,.35);font-size:11px;line-height:18px;font-variant-numeric:tabular-nums}
332.fd-rcp-totals div{display:flex;justify-content:space-between}
333.fd-rcp-grand{margin-top:6px;padding:6px 0;border-top:2px solid var(--fd-rcp-ink);border-bottom:2px solid var(--fd-rcp-ink);font-size:15px;line-height:22px;font-weight:700}
334.fd-rcp-terms{margin-top:10px;font-size:11px;line-height:16px;text-align:center}
335.fd-rcp-bar{width:100%;height:44px;margin-top:14px;color:var(--fd-rcp-ink)}
336.fd-rcp-code{margin-top:4px;text-align:center;font-size:9px;line-height:12px;letter-spacing:.1em;color:var(--mut)}
337.fd-rcp-cta{display:flex;align-items:center;justify-content:center;margin-top:16px;height:44px;background:var(--fd-rcp-ink);color:var(--fd-rcp-paper);font:700 12px/44px var(--mono);letter-spacing:.08em;text-transform:uppercase;transition:background-color var(--fd-dur-1),transform var(--fd-dur-2) var(--fd-spring)}
338.fd-rcp-cta:hover{background:var(--fd-rcp-accent)}
339.fd-rcp-cta:active{transform:scale(.96);transition-duration:var(--fd-dur-1)}
340.fd-rcp-thanks{margin-top:14px;text-align:center;font-size:10px;line-height:15px;color:var(--mut)}
341.fd-rcp-print{display:flex;align-items:center;justify-content:center;gap:8px;width:100%;margin-top:18px;height:40px;border:1.5px dashed var(--fd-rcp-ink);font:700 12px/16px var(--mono);letter-spacing:.06em;text-transform:uppercase;color:var(--fd-rcp-ink);transition:background-color var(--fd-dur-1),color var(--fd-dur-1),transform var(--fd-dur-2) var(--fd-spring)}
342.fd-rcp-print:hover{background:var(--fd-rcp-ink);color:var(--fd-rcp-paper);border-style:solid}
343.fd-rcp-print:active{transform:scale(.96);transition-duration:var(--fd-dur-1)}
344.fd-rcp-print:focus-visible,.fd-rcp-cta:focus-visible{outline:2px solid color-mix(in srgb,var(--fd-rcp-accent) 70%,transparent);outline-offset:2px}
345.fd-rcp-stamp{position:absolute;right:16px;top:124px;padding:4px 10px;border:2px solid var(--fd-rcp-accent);border-radius:4px;color:var(--fd-rcp-accent);font:700 12px/16px var(--mono);letter-spacing:.14em;text-transform:uppercase;transform:rotate(-9deg);mix-blend-mode:multiply;animation:fd-rcp-stamp var(--fd-dur-3) var(--fd-spring) 1000ms both}
346@keyframes fd-rcp-stamp{from{opacity:0;transform:rotate(-9deg) scale(1.8)}to{opacity:.9;transform:rotate(-9deg) scale(1)}}
347.fd-rcp-foot{max-width:1140px;margin:48px auto 0;font-size:11px;line-height:18px;color:#4C4B46}
348@media (max-width:900px){
349 .fd-rcp-head{grid-template-columns:1fr}
350 .fd-rcp-row{grid-template-columns:minmax(0,1fr);max-width:400px;gap:28px}
351}
352@media (prefers-reduced-motion:reduce){.fd-rcp *{transition-duration:1ms!important;animation:none!important}.fd-rcp-stamp{opacity:.9}}
353@media (prefers-contrast:more){.fd-rcp-paper{filter:none;outline:1px solid var(--fd-rcp-ink)}}
354`;
355
356export function Demo() {
357 return <ReceiptPricing />;
358}

More Pricing. Same file contract.

Pricing Tiers Toggle, pricing preview: React pricing section with three plans, a monthly and yearly toggle, odometer prices and a set-apart…

Pricing Tiers Toggle

React pricing section with three plans, a monthly and yearly toggle, odometer prices and a set-apart recommended plan.

Usage Calculator, pricing preview: Usage-based pricing calculator: snapping sliders price every plan at once, flag the cheapest and animate an…

Usage Calculator

Usage-based pricing calculator: snapping sliders price every plan at once, flag the cheapest and animate an itemised bill.

Membership Card, pricing preview: Single plan pricing as a membership card: spring 3D tilt, flips over, with honest included and excluded lists

Membership Card

Single plan pricing as a membership card: spring 3D tilt, flips over, with honest included and excluded lists.

Questions about Receipt Pricing

Run npx shadcn@latest add https://freedesignmd.com/r/receipt-pricing.json, or open the page and copy the file into your project. It has zero dependencies besides React. What you copy is the exact file that renders the live demo.

Pricing section as till receipts for studio retainers: pick one and the itemised order, VAT and barcode feed out line by line. It is a free, MIT-licensed React pricing component from the freedesignmd library.

Yes. It is free and MIT-licensed, use, modify and ship it in personal and commercial projects without attribution.

All evergreen browsers. CSS masks for the torn edge (Safari via -webkit-mask).

Yes. It is a plain React component with its own styles, so it runs in Next.js, Vite, Remix and Lovable projects, and it needs no Tailwind setup. It has zero dependencies besides React.