Overview#
Tally is a marketing website template for a SaaS or fintech product: invoicing, expenses and simple bookkeeping software for freelancers and small studios. The fictional company is Tally Software Ltd, based on Lapp’s Quay in Cork, Ireland, so prices are in euro, VAT is the Irish standard rate of 23% and the copy uses British/Irish spelling. It is plain HTML, Tailwind CSS v4 and one vanilla JavaScript file, version 1.0.0. See the live demo at freeframe.dev/templates/tally.
Money and tax copy in the template is deliberately general ("not tax advice", no guaranteed savings). If you use it for a real product, have your own figures and claims checked.
Quick start#
-
Unzip the download and open
index.htmlin a browser. Everything works from the file system. -
Or serve the folder so URLs look like a real site:
shnpx serve . -
To change styles you need Node.js 24 or newer:
shnpm install npm run watch
You only need Node if you add Tailwind classes that aren't already in assets/css/style.css.
Pages#
| File | Purpose | Notable sections and features |
|---|---|---|
index.html |
Home | Live invoice hero (edit quantities, simulate payment), customer wordmarks, three product cards, dark dashboard band with an SVG cash flow chart, set-up steps, testimonials, calculator and pricing teasers, call to action |
features.html |
Features | Product tour in ARIA tabs (invoicing, expenses, bookkeeping, reports), reminder email writer, grid of smaller features |
pricing.html |
Pricing | Monthly / annual switch, VAT toggle, three plan cards, comparison table with a sticky header, billing and VAT FAQ |
calculator.html |
Late-payment calculator | Statutory interest and compensation calculator with an SVG chart, time-saved estimate |
integrations.html |
Integrations | Category filters, search, result count, empty state, bank feed notes, API code sample |
customers.html |
Customers | Featured story with before/after bars, six stories filterable by business type |
security.html |
Security and trust | Key facts, six security practices, privacy notes, sub-processor table, responsible disclosure FAQ |
changelog.html |
Changelog | Updates filterable by type (new, improved, fixed) |
contact.html |
Book a demo | Validated demo request form, live office hours in Irish time, next steps, address |
404.html |
Not found | A "reconciliation" card and links to the main pages |
Project structure#
tally/
index.html ... 404.html the ten pages (header and footer are repeated in each)
assets/css/style.css compiled Tailwind CSS (generated, do not edit by hand)
assets/js/main.js all JavaScript: data, demos, pricing, calculators, filters, forms
assets/img/favicon.svg tally-mark logo used as the favicon
assets/img/og-image.svg source of the social sharing image
assets/img/og-image.png 1200×630 social image used by og:image
src/input.css Tailwind source: design tokens (@theme) and components
package.json build and watch scripts for the CSS
README.md short introduction
DOCS.md this guide
CHANGELOG.md version history
LICENSE.txt MIT licenseCustomising#
Brand and colours#
All design tokens are in the @theme block at the top of src/input.css. The ones that matter most:
| Token | Default | Used for |
|---|---|---|
--color-paper |
#f7f6f1 |
Page background |
--color-paper-2 |
#efede5 |
Alternate sections and table group rows |
--color-line / --color-line-strong |
#dcd9ce / #c4c0b2 |
Hairlines, card and input borders |
--color-ink |
#0d1f1b |
Text, primary buttons, dark bands and footer |
--color-ink-soft / --color-ink-faint |
#3c4a45 / #59655f |
Secondary text and captions (AA on paper and white) |
--color-fog |
#a9bab3 |
Secondary text on ink backgrounds |
--color-mint-400 / --color-mint-500 |
#45d99b / #1fc786 |
The accent: fills with ink text on top, chart bars |
--color-mint-700 / --color-mint-800 |
#0a6e4c / #08573d |
Accent text and links on light backgrounds |
--color-amber-*, --color-rose-*, --color-sky-* |
Invoice status chips (due, late, sent) |
To change the accent, edit the mint scale and rebuild:
--color-mint-400: #7cc4ff; /* fills */
--color-mint-700: #1d5f99; /* text on paper */Keep the 700/800 shades dark enough for AA contrast on --color-paper. A few SVGs (logo, charts, OG image)
use hex colours directly; search for #45d99b and #0d1f1b to update them too.
Typography#
Two Google Fonts are loaded in the <head> of every page:
- Mona Sans (variable weight and width) for everything. Headings use
font-stretch: 112%(set insrc/input.cssforh1–h3), and the whole body usesfont-variant-numeric: tabular-numsso figures line up in invoices and tables. - Azeret Mono for small labels (
.eyebrow, invoice numbers, chart axes).
To swap fonts, change the <link> URL in every page and the --font-sans and --font-mono tokens. If the new
family has no width axis, remove the font-stretch rules or they are simply ignored.
Content#
Text lives in the HTML pages. Data that the JavaScript uses sits at the top of assets/js/main.js
(section 1):
| Constant | What it holds |
|---|---|
LOCALE, CURRENCY |
'en-IE' and 'EUR', used by money() to format every amount |
VAT_RATE |
0.23, used by the hero invoice and the pricing VAT toggle |
PLANS |
solo, studio, practice: name, monthly (per month) and annual (per year) prices in euro, excluding VAT |
BILLING_PERIODS, DEFAULT_BILLING |
Accepted billing values and the default ('annual') |
STORAGE_KEYS |
localStorage key names (see Saved data) |
INVOICE_DEMO |
Quantity limits (1–9), stage names and the notes under the hero invoice |
INVOICE_DUE |
Due date of the sample invoice INV-0142 ('YYYY-MM-DD'). null (the default) is a rolling demo date: the Friday 7 to 13 days from today, so the demo invoice is never overdue |
REMINDER_SAMPLE, REMINDER_TONES |
Sample values and the reminder emails per tone (friendly, neutral, firm) and stage (before, due, late) |
LATE_PAYMENT |
Margin over the reference rate (8 points), days in a year and compensation tiers |
TIME_ASSUMPTIONS |
Minutes per invoice with Tally (4) and the reduction in chasing (0.8) |
CALC_FIELDS |
Default, minimum and maximum for every calculator input |
CALC_URL_PARAMS |
Calculator fields read from and written to the URL (amount, days) |
INTEGRATION_CATEGORIES |
Category keys accepted from integrations.html?category= |
OFFICE |
Office hours on the contact page: timeZone: 'Europe/Dublin', weekdays 1–5, 9:00 to 17:30 |
Changing a price. Edit PLANS and the starting values in the plan cards on pricing.html (the HTML shows
the annual prices before JavaScript runs). Annual is ten months for twelve, so keep annual = monthly × 10 if
you want the "2 months free" label and the FAQ to stay true:
const PLANS = {
solo: { name: 'Solo', monthly: 12, annual: 120 },
studio: { name: 'Studio', monthly: 30, annual: 300 },
practice: { name: 'Practice', monthly: 60, annual: 600 }
};Adding an integration. Copy one <li data-integration ...> in integrations.html, set data-category
to one of the INTEGRATION_CATEGORIES keys and update the counts in the filter pills:
<li data-integration data-category="payments" data-name="kittiwake pay">
<article class="card card-hover flex h-full flex-col p-5"> ... </article>
</li>A new category needs a pill (<button class="pill" data-filter="your-key">) and a key in
INTEGRATION_CATEGORIES. Customer stories and changelog entries use the same markup (data-integration and
data-category) with their own pills.
Hero invoice lines. Each row in #invoice-demo has data-line and data-price (unit price excluding VAT).
Edit the name, price and starting quantity in the HTML; totals are recalculated on load.
Images and artwork#
There are no photos. The logo, customer wordmarks, integration tiles, avatars and charts are inline SVG, and
the invoice, dashboard and receipts are HTML. If you replace an avatar with a photo, keep it square (40×40 on
the home page, 56×56 on the contact page). The social image is assets/img/og-image.png (1200×630),
generated from og-image.svg; edit the SVG and export a new PNG with any SVG editor. The favicon is
assets/img/favicon.svg.
Navigation, header and footer#
The header and footer are repeated in every page. To add a page, copy an existing one, change its <title>,
description and Open Graph tags, add a link to the <nav aria-label="Main"> list (desktop), the
<nav aria-label="Mobile"> list and the footer in every page, and set aria-current="page" on the link
in the page's own header. The desktop menu shows six links; Changelog is only in the mobile menu and the
footer.
Features#
Hero invoice demo#
index.html, #invoice-demo; code in initInvoiceDemo() (section 4). The minus and plus buttons change a
line's quantity between INVOICE_DEMO.minQty and maxQty; the subtotal, VAT (VAT_RATE, rounded to the
cent) and total update and the total briefly flashes. Simulate payment moves the invoice from Viewed to
Paid: the chip turns green, the progress track completes, a "Payment received" card appears and the
quantities lock. The button then reads Reset demo. With the starting quantities the total is €4,329.60
(€3,520.00 + €809.60 VAT). Changes are announced in a polite live region; the progress list marks the current
stage with aria-current="step".
Product tour tabs#
features.html; initTabs() (section 5). Standard ARIA tabs: arrow keys, Home and End move between tabs.
The panel ids are invoicing, expenses, books and reports, so features.html#expenses opens that tab
(only ids of the tab panels are accepted). The pill links at the top of the page (data-tab-link) select a tab
and scroll to it. Without JavaScript every panel is visible.
Reminder writer#
features.html#reminders; initReminders() (section 6). Tone buttons (data-tone, aria-pressed) and stage
radios (name="reminder-stage") choose one of the nine emails in REMINDER_TONES; {invoice}, {amount},
{due}, {dueDay} and {client} are filled from REMINDER_SAMPLE; {due} ("Friday 30 October") and
{dueDay} ("Friday") come from INVOICE_DUE, like the "Due" date on the hero invoice (time[data-invoice-due]).
The preview card is a polite live region.
Pricing switch and VAT toggle#
pricing.html; initPricing() (section 7). The billing buttons (data-billing) show either the monthly
price or the annual price divided by twelve, with "Billed €300 yearly" or "Billed monthly" underneath. The
saving is monthly × 12 − annual per plan: €24, €60 and €120 a year. The checkbox data-vat-toggle
multiplies everything by 1 + VAT_RATE (Studio monthly becomes €36.90) and changes the VAT note. Whole euro
amounts drop the cents (money(n, true)). The choice is saved (see below) and ?billing=monthly or
?billing=annual in the URL overrides it; clicking a billing button updates the URL. A status line announces
the change to screen readers. The comparison table header is sticky below the site header.
Late-payment calculator#
calculator.html; initCalculators() and lateClaim() (section 8). Inputs: amount (€1 to €1,000,000),
days overdue (1 to 365, a slider) and the ECB reference rate (0 to 10%). The rules:
- statutory rate = reference rate +
LATE_PAYMENT.margin(8 points); - interest = amount × statutory rate ÷ 100 × days ÷ 365, rounded to the cent;
- compensation = €40 under €1,000, €70 from €1,000 to under €10,000, €100 from €10,000.
With the defaults (€2,400, 45 days, 2.15%) the claim is €100.03: 10.15%, €30.03 interest, €0.67 a day, €70
compensation. calculator.html?amount=12000&days=1 gives €103.34. The chart is drawn in drawChart() as inline
SVG: compensation as a flat band, interest on top, a marker at the chosen day, over 90 days or the next multiple
of 30. Its text description (data-chart-desc) is rewritten each time. Out-of-range numbers mark the input
aria-invalid and leave the last valid result. Reset to the example restores the defaults, clears the
saved values and removes the URL parameters. Update the default reference rate in CALC_FIELDS.rate.def and
the HTML when the ECB rate changes.
Time-saved estimate#
calculator.html#time-saved; renderTime() in section 8. Minutes today = invoices × minutes per invoice +
late invoices × chasing minutes. With Tally = invoices × TIME_ASSUMPTIONS.minutesPerInvoice + late invoices
× chasing × (1 − chaseReduction). The defaults give 6.7 hours today, 1.3 hours with Tally and 5.4 hours
saved, worth €322 at €60 an hour. Hours read "1 hour" or "2.5 hours".
Filters and search#
integrations.html, customers.html and changelog.html; initFilters() (section 9). Each
[data-directory] has pills (data-filter, aria-pressed), items (data-integration with data-category),
an optional search box (data-search, matches the item's text), a live count (data-result-count, with
data-noun/data-nouns for "1 story" / "6 stories") and an empty state (data-empty) with a clear button.
On the integrations page data-url-param="category" reads ?category=banking (checked against
INTEGRATION_CATEGORIES with an own-key check) and keeps the URL in step with the pills.
Office hours#
contact.html; officeState() and initOfficeHours() (section 10). The time is worked out in
OFFICE.timeZone (Europe/Dublin) whatever the visitor's time zone, and refreshed every minute. Inside
Monday–Friday 9:00–17:30 it reads "It’s 10:42 in Cork and we’re in the office"; outside, it says when the team
replies ("after 9:00 this morning", "tomorrow" or "on Monday").
Header, menu, reveals and back to top#
The header gains a shadow once you scroll. Below 1024px a menu button (aria-expanded, aria-controls)
opens the mobile menu; Esc closes it. Elements with .reveal fade in when scrolled into view (off with reduced
motion). data-count numbers count up once. The back-to-top button is a separate block at the end of
main.js; delete it and the .back-to-top styles to remove it.
Forms#
Two forms, validated in section 10 of main.js (validate(), setFieldError()), with errors shown under
each field, linked with aria-describedby and aria-invalid, and focus moved to the first problem. After the
first submit, fields re-validate as they are fixed.
| Form | Where | Validates |
|---|---|---|
Newsletter (data-form="newsletter") |
Footer of every page | Email required and well formed |
Demo request (data-form="demo") |
contact.html |
Name, work email, team size (radio group) and consent required; message up to 500 characters with a counter |
contact.html?plan=studio (or solo, practice) preselects the plan; the plan buttons on the pricing page
link there. The demo forms don't send anything: on success they are replaced by a message that says so.
To connect a form service such as Formspree, replace the successSwap(...) call in initForms():
onValidSubmit(form, () => {
fetch('https://formspree.io/f/your-id', {
method: 'POST',
body: new FormData(form),
headers: { Accept: 'application/json' }
}).then((r) => {
if (r.ok) successSwap(form, '<p class="font-semibold">Thanks, we’ll be in touch.</p>');
else form.querySelector('[data-form-status]').textContent = 'Something went wrong. Please try again.';
});
});Also set action="https://formspree.io/f/your-id" and method="post" on the <form> so it still works
without JavaScript.
Saved data (localStorage)#
All access goes through one safeStorage object: localStorage when available, an in-memory stand-in when
the browser blocks it (sandboxed iframes, strict privacy settings), so features keep working for the page view.
| Key | Shape | Validation on read |
|---|---|---|
tally:pricing |
{ "billing": "annual", "vat": false } |
billing must be an own key of BILLING_PERIODS, vat a boolean; anything else falls back to annual without VAT |
tally:calculator |
{ "amount": 2400, "days": 45, "rate": 2.15, "invoices": 12, "minutes": 20, "late": 30, "chase": 45, "hourly": 60 } |
Each field must be an own key of CALC_FIELDS and a number within its range; invalid fields keep their defaults |
URL parameters win over saved values: pricing.html?billing=, calculator.html?amount=&days=. Other URL
parameters are integrations.html?category= and contact.html?plan=; every one is checked with
Object.prototype.hasOwnProperty.call(...) or a range check, so values like ?plan=constructor are ignored.
To reset, use Reset to the example on the calculator, or run
localStorage.removeItem('tally:pricing') in the browser console. To stop saving, remove the store.set(...)
calls in sections 7 and 8.
SEO and sharing#
Each page has its own <title>, meta description, Open Graph tags, theme-color and lang="en-IE".
og:image points at assets/img/og-image.png with a relative URL; social sites need an absolute URL, so
change it to your domain when you deploy:
<meta property="og:image" content="https://www.example.ie/assets/img/og-image.png">Add a sitemap.xml listing the nine pages and a robots.txt pointing to it. There is no structured data;
SoftwareApplication with offers is a good fit for a product like this.
Accessibility#
- Skip link, landmarks (
header,navwith labels,main,footer), one<h1>per page and a logical heading order. - Visible focus outlines in the accent colour, AA contrast on every surface,
prefers-reduced-motionrespected (reveals, chart animation, count-up and smooth scrolling are off). - ARIA patterns: tabs, pressed buttons for filters and billing,
aria-currentfor navigation and invoice stage, live regions for totals, counts, calculator results and the pricing status. - Charts have text alternatives (
aria-labelor a<desc>that is updated with the numbers); tables have captions and header scopes.
When editing, keep one <h1> per page, give new inputs a <label>, and make sure every aria-controls,
aria-labelledby and aria-describedby points at an id that exists.
Building and deploying#
Rebuild the CSS after adding Tailwind classes:
npm install
npm run build # or: npm run watchClasses used only inside JavaScript strings must also appear somewhere Tailwind scans (main.js is scanned).
Then upload the whole folder to any static host: Netlify Drop, GitHub Pages, Cloudflare Pages or your own
server. There is no build step for the HTML.
Troubleshooting#
- New Tailwind classes have no effect. Run
npm run build; the compiledassets/css/style.cssonly contains classes that existed when it was built. - Prices or the calculator don't remember choices. The browser is blocking storage (private mode or a sandboxed preview), so choices last only for the page view. That's expected.
- A shared pricing link opens on the wrong period.
?billing=overrides the saved choice; remove it from the link. - Prices on the cards are wrong before the page finishes loading. The HTML shows annual prices; update them
in
pricing.htmlas well as inPLANS. - The office hours text looks wrong. It uses Irish time (
OFFICE.timeZone); change the time zone and hours inOFFICEfor another location. - Fonts look different. Google Fonts is blocked or offline; the system sans-serif fallback is used. Host the fonts yourself if you need them offline.
- The social image doesn't show.
og:imagemust be an absolute URL on your live domain.
License#
MIT. The code and all included artwork (logo, wordmarks, integration tiles, avatars, charts, mock-ups and social image) are free for personal and commercial use; see LICENSE.txt in the download.
Mona Sans and Azeret Mono are licensed under the SIL Open Font License.
Tally, Tally Software Ltd and every business, person and integration named in the template are fictional placeholders.