Design system · Version 5.0
The EU VAT Info design system
Tokens, components and page patterns behind EU VAT Info. Every example renders with the production stylesheet, so what you see here is what ships.
- Colour tokens
- 39
- Shape, depth and motion tokens
- 7
- Component classes
- 35
- Icons
- 58
Get a design system like this for your business on BusinessPress
Principles
Seven decisions every page follows. They explain the rules further down and settle anything the rules do not cover.
the institutional reference desk
People come here to get a rate, a total or a validation result they can put on an invoice. The interface should feel like a trustworthy public-sector or financial tool: flat EU navy for identity, one thin line of EU gold as the signature, white paper-like surfaces with hairline borders, square corners and figures set in tabular type. Nothing glows, floats, blurs or bounces.
-
Solid, not shiny
Surfaces are opaque. Depth comes from hairline borders and one soft shadow for things that float (menus, the palette, toasts). No glass, blur, glow, aurora or decorative gradients.
-
Navy is identity, gold is the signature, blue is action
Navy (
brand) frames the page: header, heroes and footer. Gold marks "you are here" and the brand mark, in small doses. Action blue marks what you can press or what is selected. Status colours mean status and nothing else. -
Numbers are the hero
Results use the largest type on the view, tabular figures and a plain-language summary line.
-
Square geometry
4px controls, 6px cards, 8px panels. Rectangles read as precise and institutional; pills read as playful.
-
Honest states
Loading, empty, error and "service unavailable" states are designed, worded and announced; nothing fails silently.
-
Accessible by default
WCAG 2.2 AA in both themes, a visible focus ring on every surface (gold on navy), 44px touch targets and reduced-motion support.
-
Quiet motion
Colour changes take 150ms. Menus fade in with a 4px drop. Nothing scales, springs or loops.
Colour
Semantic tokens carry meaning, not hue. Each one has a light and a dark value, and the .dark class on the html element swaps them, so templates never need dark: variants.
Surfaces
-
workspace- Light
- #f4f6f9
- Dark
- #090d14
Page canvas behind cards
-
surface- Light
- #ffffff
- Dark
- #11161f
Cards, fields, tables, menus
-
surface-subtle- Light
- #f7f9fc
- Dark
- #151b25
Table heads, result panes, sticky bars inside cards
-
surface-muted- Light
- #edf0f5
- Dark
- #1c232f
Notes, tracks, hover fills, placeholders
-
line- Light
- #dde2e8
- Dark
- #272e3a
Hairlines between rows and around cards
-
line-strong- Light
- #c5cbd4
- Dark
- #3a4352
Field and control outlines, popover edges
Text
-
ink- Light
- #0f1828
- Dark
- #f1f4f8
Headings, values, primary text
-
ink-muted- Light
- #4a5364
- Dark
- #b7bec8
Supporting text and labels (AA on every surface)
-
ink-quiet- Light
- #646c7b
- Dark
- #949ca8
Placeholders, disabled text, meta
Brand
-
brand- Light
- #132b5a
- Dark
- #0e1d3a
EU navy: heroes, navy cards and panels
-
brand-deep- Light
- #0b1e44
- Dark
- #091429
Header, footer, text on white buttons
-
brand-soft- Light
- #ecf3fe
- Dark
- #1b2942
Brand-tinted panels on light surfaces
Action
-
action- Light
- #2159bf
- Dark
- #81b4f6
Links, selection, focus, data bars (never behind white text)
-
action-deep- Light
- #16479d
- Dark
- #a5cffe
Text on
action-soft, hovered links -
action-soft- Light
- #eaf2fe
- Dark
- #1c2b47
Selected chips, active rows, icon wells
-
button- Light
- #2159bf
- Dark
- #295ebd
Fills that carry white text (stays saturated in dark)
-
button-hover- Light
- #16479d
- Dark
- #3a70d1
Hover for
buttonfills
Status
-
success- Light
- #007748
- Dark
- #63d99b
Verified, valid, decrease in rate
-
success-soft- Light
- #e0faea
- Dark
- #103324
Verified, valid, decrease in rate
-
warning- Light
- #ae4900
- Dark
- #f9ba5f
Needs attention, scheduled
-
warning-soft- Light
- #fff2d7
- Dark
- #42270e
Needs attention, scheduled
-
danger- Light
- #be2323
- Dark
- #fe8b83
Invalid, error, increase in rate
-
danger-soft- Light
- #ffeeee
- Dark
- #4b1d1b
Invalid, error, increase in rate
Fixed colours
The same in both themes: EU gold for small accents on navy, and the code surfaces, which stay dark.
-
gold#ffcc00 -
code#0a0f1a -
code-raised#151c29 -
code-muted#272e3c
Syntax colours
For code samples on the dark code surface, in both themes.
- keyword #81d3fd
- function #f5e05d
- string #84efaa
- literal #ffbf67
- comment #9aa5b8
- ok #3fd59b
- error #fb8087
VAT map ramp
A single-hue ordinal ramp, checked for colour-vision deficiency. It flips in dark mode so higher rates gain contrast against the dark surface, and every region is also listed as text.
Contrast pairs
WCAG 2.2 contrast ratios computed from the tokens above. Body text needs 4.5:1; large text, icons and borders need 3:1.
| Pair | Tokens | Light | Dark |
|---|---|---|---|
| Body text ink on surface | ink on surface | 17.7:1 AAA | 16.4:1 AAA |
| Supporting text ink-muted on surface | ink-muted on surface | 7.8:1 AAA | 9.7:1 AAA |
| Meta and placeholders ink-quiet on surface | ink-quiet on surface | 5.3:1 AA | 6.5:1 AA |
| Links and selection action on surface | action on surface | 6.5:1 AA | 8.4:1 AAA |
| Selected chip action-deep on action-soft | action-deep on action-soft | 7.7:1 AAA | 8.7:1 AAA |
| Success success on success-soft | success on success-soft | 5.1:1 AA | 7.8:1 AAA |
| Warning warning on warning-soft | warning on warning-soft | 5.1:1 AA | 8.1:1 AAA |
| Danger danger on danger-soft | danger on danger-soft | 5.4:1 AA | 6.2:1 AA |
| Button label white on button | white on button | 6.5:1 AA | 6.2:1 AA |
| Text on navy white on brand | white on brand | 13.8:1 AAA | 16.8:1 AAA |
| Gold on navy gold on brand | gold on brand | 9.1:1 AAA | 11.1:1 AAA |
Typography
Inter Variable, self-hosted with the optical-size axis, so large text uses Inter's display cut. Headings stay one colour, and every figure that can change or be compared is set in tabular numbers.
Display
Hero headings and hero results
3rem · 700 · 1.08
€1,487.50
text-4xl sm:text-5xl font-bold tracking-[-0.03em]
Headline
Page header titles
2.5rem · 700 · 1.1
VAT rate change history
text-3xl sm:text-[2.5rem] font-bold tracking-[-0.035em]
Section
Section headings inside a page
Recorded changes
text-xl font-bold
Title
Card titles
1.125rem · 700 · 1.35
Germany VAT rates and formulas
text-lg font-bold
Body
Paragraphs, capped at 68–72 characters
1rem · 400 · 1.6
VIES forwards your query to the national VAT database of the member state that issued the number and returns its registration status.
text-base leading-7 text-ink-muted
Small
Helper text and table cells
The country is detected from the VAT number prefix.
text-sm text-ink-muted
Label
Field and data labels, in sentence case
0.8125rem · 600 · 1.35
Amount (excl. VAT)
text-[0.8125rem] font-semibold text-ink-muted
Eyebrow and kicker
One short uppercase label above a heading: eyebrow on light surfaces, kicker with the gold square on navy
Key information
2026 rates · 27 member states
app-eyebrow · app-kicker
Tabular figures
Digits share one width, so columns of money and rates line up
Default
€1,111.11
€9,090.00
With tabular
€1,111.11
€9,090.00
Families
From the --font-sans and --font-mono tokens
Aa 400 Aa 500 Aa 600 Aa 700
Inter Variable, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif, Apple Color Emoji, Segoe UI Emoji
LT100019070512 · country_code
ui-monospace, SF Mono, Cascadia Code, JetBrains Mono, Menlo, Consolas, monospace
Shape
Three radius tokens keep every corner square and precise. Pills read as playful, so rounded-full is kept for dots, spinners, avatars and numbered steps.
-
rounded-control4pxButtons, fields, chips, segmented track, menu rows, icon wells, notes
-
rounded-card6pxapp-surfacecards, popovers, menus, code blocks, brand panels -
rounded-panel8pxThe primary task card (
app-surface-raised), the command palette, navy CTA blocks -
rounded-xs2pxFlags, badges and bar ends
-
rounded-full50%Dots, spinners, avatars and numbered steps
Elevation
Depth comes from hairline borders first. Three shadows remain: resting cards, the one primary task card on a hero, and things that float above the page.
-
shadow-cardResting cards and secondary buttons (built into
app-surface)1 layer
-
shadow-workflowThe primary task card on a hero (built into
app-surface-raised)2 layers
-
shadow-floatingMenus, popovers, the palette, toasts (built into
app-popover)2 layers
Motion
Quiet and quick. Colour changes take the short duration, position changes the longer one, both on the out-quint curve. Nothing scales, springs or loops, and reduced-motion settings collapse every duration.
Easing
ease-out-quint
cubic-bezier(0.22, 1, 0.36, 1)
Colour changes
150ms
Pressable feedback on colour, borders and shadows
Position changes
200ms
Sliding thumbs and menus that drop 4px
Position change at 200ms on ease-out-quint
Layout
One page width, a four-pixel spacing grid and grids that start from a single column, so no page scrolls sideways at 320px.
app-container
- Phones
- 16px gutters
- From sm
- 24px gutters
- From lg
- 32px gutters
| Prefix | Min width |
|---|---|
| sm: | 640px |
| md: | 768px |
| lg: | 1024px |
| xl: | 1280px |
| 2xl: | 1536px |
| Where | Classes |
|---|---|
| Between sections | py-8 sm:py-10 |
| Hero bottom | pb-12 sm:pb-16 |
| Card padding | p-5 sm:p-6 |
| Card grids | gap-4 or gap-6 |
| Reading width | max-w-[68ch] |
Form fields
Labels sit above the field, help and errors below, linked with aria-describedby. Validate on the client for instant feedback and again on the server.
Text field
app-field with help text
As registered with the national tax authority.
Blade
<div class="max-w-sm">
<label for="sg-company" class="mb-1.5 block text-[0.8125rem] font-semibold text-ink-muted">Company name</label>
<input id="sg-company" type="text" class="app-field" placeholder="Example GmbH" aria-describedby="sg-company-help">
<p id="sg-company-help" class="mt-1.5 text-xs text-ink-muted">As registered with the national tax authority.</p>
</div>
Error
aria-invalid plus a message that says what to fix
German VAT numbers have nine digits after DE.
Blade
<div class="max-w-sm">
<label for="sg-vat-number" class="mb-1.5 block text-[0.8125rem] font-semibold text-ink-muted">VAT number</label>
<input
id="sg-vat-number"
type="text"
value="DE12345"
class="app-field border-danger font-mono focus:border-danger focus:ring-danger/15"
aria-invalid="true"
aria-describedby="sg-vat-number-error"
>
<p id="sg-vat-number-error" class="mt-1.5 text-xs font-medium text-danger">German VAT numbers have nine digits after DE.</p>
</div>
Select
app-select keeps the native control
Blade
<div class="max-w-sm">
<label for="sg-rate-type" class="mb-1.5 block text-[0.8125rem] font-semibold text-ink-muted">Rate type</label>
<select id="sg-rate-type" class="app-select">
<option>Standard</option>
<option>Reduced</option>
<option>Super-reduced</option>
<option>Parking</option>
</select>
</div>
Prefix and search
Money inputs are tabular
Blade
<div class="grid max-w-xl grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label for="sg-amount" class="mb-1.5 block text-[0.8125rem] font-semibold text-ink-muted">Amount (excl. VAT)</label>
<div class="relative">
<span class="pointer-events-none absolute inset-y-0 left-3.5 flex items-center font-semibold text-ink-quiet" aria-hidden="true">€</span>
<input id="sg-amount" type="text" inputmode="decimal" value="1,250.00" class="app-field tabular h-12 pl-10 text-lg font-semibold">
</div>
</div>
<div>
<label for="sg-search" class="mb-1.5 block text-[0.8125rem] font-semibold text-ink-muted">Country</label>
<div class="relative">
<x-ui.icon name="search" class="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-ink-quiet" />
<input id="sg-search" type="search" placeholder="Search countries…" class="app-field h-12 pl-10">
</div>
</div>
</div>
Selection controls
A segmented control for two equal modes, chips for choosing among values, and chip tabs for switching panels.
Segmented control
The thumb slides 200ms; data-value is rendered on the server too
Blade
<div x-data="{ mode: 'exclude' }">
<div role="radiogroup" aria-label="Calculation mode" class="app-segmented" data-value="exclude" :data-value="mode">
<span class="app-segmented-thumb" aria-hidden="true"></span>
<button type="button" role="radio" aria-checked="true" :aria-checked="(mode === 'exclude').toString()" class="app-segment" x-on:click="mode = 'exclude'">
<x-ui.icon name="plus" class="size-3.5" />
Add VAT
</button>
<button type="button" role="radio" aria-checked="false" :aria-checked="(mode === 'include').toString()" class="app-segment" x-on:click="mode = 'include'">
<x-ui.icon name="minus" class="size-3.5" />
Remove VAT
</button>
</div>
</div>
Chips
app-chip-active with aria-pressed; dashed for “add custom”
Blade
<div class="flex flex-wrap gap-2" role="group" aria-label="VAT rate" x-data="{ rate: 19 }">
<button type="button" class="app-chip app-chip-active min-h-10 px-3.5" :class="{ 'app-chip-active': rate === 19 }" aria-pressed="true" :aria-pressed="(rate === 19).toString()" x-on:click="rate = 19">
<span class="font-medium opacity-80">Standard</span>
<span class="tabular">19%</span>
</button>
<button type="button" class="app-chip min-h-10 px-3.5" :class="{ 'app-chip-active': rate === 7 }" aria-pressed="false" :aria-pressed="(rate === 7).toString()" x-on:click="rate = 7">
<span class="font-medium opacity-80">Reduced</span>
<span class="tabular">7%</span>
</button>
<button type="button" class="app-chip min-h-10 border-dashed px-3.5">
<x-ui.icon name="plus" class="size-3.5" />
Custom %
</button>
</div>
Chip tabs
A tablist with arrow-key movement
Open Settings → Connectors and add the server URL.
Add the server to ~/.cursor/mcp.json.
Connect with Streamable HTTP and no authentication.
Blade
<div x-data="{
tab: 'claude',
tabs: ['claude', 'cursor', 'other'],
move(step) {
this.tab = this.tabs[(this.tabs.indexOf(this.tab) + step + this.tabs.length) % this.tabs.length];
this.$nextTick(() => document.getElementById('sg-tab-' + this.tab).focus());
},
}">
<div role="tablist" aria-label="AI client" class="flex flex-wrap gap-2" x-on:keydown.arrow-right.prevent="move(1)" x-on:keydown.arrow-left.prevent="move(-1)">
@foreach(['claude' => 'Claude', 'cursor' => 'Cursor', 'other' => 'Other clients'] as $key => $label)
<button
type="button"
role="tab"
id="sg-tab-{{ $key }}"
aria-controls="sg-panel-{{ $key }}"
aria-selected="{{ $loop->first ? 'true' : 'false' }}"
:aria-selected="(tab === @js($key)).toString()"
tabindex="{{ $loop->first ? 0 : -1 }}"
:tabindex="tab === @js($key) ? 0 : -1"
@class(['app-chip', 'app-chip-active' => $loop->first])
:class="{ 'app-chip-active': tab === @js($key) }"
x-on:click="tab = @js($key)"
>{{ $label }}</button>
@endforeach
</div>
@foreach(['claude' => 'Open Settings → Connectors and add the server URL.', 'cursor' => 'Add the server to ~/.cursor/mcp.json.', 'other' => 'Connect with Streamable HTTP and no authentication.'] as $key => $step)
<p role="tabpanel" id="sg-panel-{{ $key }}" aria-labelledby="sg-tab-{{ $key }}" tabindex="0" class="mt-4 text-sm text-ink-muted" x-show="tab === @js($key)" @unless($loop->first) x-cloak @endunless>{{ $step }}</p>
@endforeach
</div>
Surfaces
Opaque white paper with hairline borders. Cards hold content, the raised card holds the primary task, popovers float, and brand panels sit on navy.
Card
app-surface on the workspace
Key information
Germany VAT rates
A card sits on the workspace with a hairline border and the resting card shadow.
Blade
<div class="app-surface max-w-md p-5 sm:p-6">
<p class="app-eyebrow">Key information</p>
<h4 class="mt-1 text-lg font-bold text-ink">Germany VAT rates</h4>
<p class="mt-2 text-sm leading-6 text-ink-muted">A card sits on the workspace with a hairline border and the resting card shadow.</p>
</div>
Raised task card
app-surface-raised, once per view, on a hero
Total (incl. VAT)
€1,487.50
€1,250.00 plus 19% VAT in Germany
Blade
<div class="app-surface-raised max-w-md p-5 text-ink sm:p-6">
<p class="text-[0.8125rem] font-semibold text-ink-muted">Total (incl. VAT)</p>
<p class="tabular mt-1 text-4xl font-bold tracking-[-0.035em] text-ink">€1,487.50</p>
<p class="mt-2 text-sm text-ink-muted">€1,250.00 plus 19% VAT in Germany</p>
</div>
Popover
app-popover for menus, the palette and toasts
Theme
Blade
<div class="app-popover w-64 p-1.5">
<p class="px-2.5 pb-1 pt-1.5 text-xs font-semibold text-ink-muted">Theme</p>
@foreach([['monitor', 'System'], ['sun', 'Light'], ['moon', 'Dark']] as [$icon, $label])
<button type="button" @class(['flex min-h-10 w-full items-center gap-2.5 rounded-control px-2.5 text-left text-sm', 'bg-action-soft font-semibold text-action-deep' => $loop->first, 'text-ink hover:bg-surface-muted' => ! $loop->first])>
<x-ui.icon :name="$icon" class="size-4" />
{{ $label }}
</button>
@endforeach
</div>
Brand panel
app-brand-panel for tiles and fields on navy
- Standard
- 19%
- Reduced
- 7%
- Currency
- EUR
Blade
<div class="grid max-w-lg grid-cols-3 gap-3">
@foreach([['Standard', '19%'], ['Reduced', '7%'], ['Currency', 'EUR']] as [$label, $value])
<dl class="app-brand-panel flex flex-col-reverse p-3 text-center">
<dt class="text-[0.6875rem] font-semibold tracking-[0.08em] text-white/70 uppercase">{{ $label }}</dt>
<dd class="tabular text-2xl font-bold text-white">{{ $value }}</dd>
</dl>
@endforeach
</div>
Note
app-note: a quiet grey aside with an info icon
The standard rate applies to most goods and services.
Blade
<p class="app-note max-w-lg">
<x-ui.icon name="info" class="mt-1 size-4 text-action" />
<span>The standard rate applies to most goods and services.</span>
</p>
Text and code
Links, keyboard hints, badges, long-form prose, code and disclosures. Status badges always carry an icon or a word, never colour alone.
Links, keys and inline code
app-link, app-kbd and app-eyebrow
Eyebrow above a heading
Read the VIES guidance, press ⌘ K to search, and send
country_code with every request.
Blade
<div class="max-w-xl space-y-3">
<p class="app-eyebrow">Eyebrow above a heading</p>
<p class="text-base leading-7 text-ink-muted">
Read the <a href="#content" class="app-link">VIES guidance</a>, press <kbd class="app-kbd">⌘</kbd> <kbd class="app-kbd">K</kbd> to search, and send
<code class="rounded-xs bg-surface-muted px-1.5 py-0.5 font-mono text-[0.875em] text-ink">country_code</code> with every request.
</p>
</div>
Badges
app-badge with a tone pair
Blade
<div class="flex flex-wrap items-center gap-2">
<span class="app-badge bg-action-soft text-action-deep">2026 rates</span>
<span class="app-badge bg-surface-muted text-ink-muted">Standard</span>
<span class="app-badge bg-success-soft text-success">
<x-ui.icon name="check-circle" class="size-3.5" />
Valid
</span>
<span class="app-badge bg-warning-soft text-warning">
<x-ui.icon name="clock" class="size-3.5" />
Scheduled
</span>
<span class="app-badge bg-danger-soft text-danger">
<x-ui.icon name="x-circle" class="size-3.5" />
Invalid
</span>
</div>
Prose
app-prose for Markdown and long translations
How a VIES check works
VIES forwards the query to the national database of the member state that issued the number. Greece uses EL rather than its ISO code GR.
- Registration status for intra-EU trade
- Company name and address, where the member state shares them
Results are reference data, not tax advice.
Blade
<div class="app-prose max-w-[68ch]">
<h4>How a VIES check works</h4>
<p>VIES forwards the query to the national database of the member state that issued the number. <strong>Greece uses EL</strong> rather than its ISO code GR.</p>
<ul>
<li>Registration status for intra-EU trade</li>
<li>Company name and address, where the member state shares them</li>
</ul>
<blockquote>Results are reference data, not tax advice.</blockquote>
</div>
Code block
app-code stays dark in both themes
GET /api/v1/calculate?amount=100&country=DE
200// 100 net at the German standard rate { "net": 100, "vat": 19, "gross": 119, "currency": "EUR" }
Blade
<div class="app-code max-w-xl">
<div class="flex items-center justify-between gap-3 border-b border-white/10 py-2 pl-4 pr-3">
<p class="font-mono text-xs"><span class="font-semibold text-syntax-ok">GET</span> <span class="text-white/60">/api/v1/calculate?amount=100&country=DE</span></p>
<span class="font-mono text-xs text-syntax-ok">200</span>
</div>
<pre tabindex="0" class="overflow-x-auto p-4 font-mono text-xs leading-5 text-white/85"><span class="text-syntax-comment">// 100 net at the German standard rate</span>
{
<span class="text-syntax-function">"net"</span>: <span class="text-syntax-literal">100</span>,
<span class="text-syntax-function">"vat"</span>: <span class="text-syntax-literal">19</span>,
<span class="text-syntax-function">"gross"</span>: <span class="text-syntax-literal">119</span>,
<span class="text-syntax-function">"currency"</span>: <span class="text-syntax-string">"EUR"</span>
}</pre>
</div>
Disclosure
Native details and summary for FAQs
Do I need an account?
No. Every tool is free and needs no sign-up.
Are the answers tax advice?
No. The rates are reference data. Check important cases with a tax adviser.
Blade
<div class="app-surface max-w-2xl divide-y divide-line">
@foreach([
['Do I need an account?', 'No. Every tool is free and needs no sign-up.'],
['Are the answers tax advice?', 'No. The rates are reference data. Check important cases with a tax adviser.'],
] as [$question, $answer])
<details class="group" @if($loop->first) open @endif>
<summary class="flex min-h-14 cursor-pointer list-none items-center justify-between gap-4 px-5 py-4 font-semibold text-ink [&::-webkit-details-marker]:hidden">
{{ $question }}
<x-ui.icon name="chevron-down" class="size-4 text-ink-quiet transition-transform duration-150 group-open:rotate-180" />
</summary>
<p class="px-5 pb-4 text-sm leading-6 text-ink-muted">{{ $answer }}</p>
</details>
@endforeach
</div>
Data display
Numbers are the hero. Tables right-align figures in tabular type, breakdowns use definition lists, and bars run on an action track with square ends.
Table
app-table inside a card, scrollable on phones
| Country | Standard | Reduced | VAT on €100 |
|---|---|---|---|
|
|
19% | 7% | €19.00 |
|
|
20% | 5.5% · 10% | €20.00 |
|
|
27% | 5% · 18% | €27.00 |
Blade
<div class="app-surface overflow-hidden">
<div class="relative overflow-x-auto">
<table class="app-table min-w-[30rem]">
<thead>
<tr>
<th scope="col" class="pl-5">Country</th>
<th scope="col" class="text-right">Standard</th>
<th scope="col" class="text-right">Reduced</th>
<th scope="col" class="pr-5 text-right">VAT on €100</th>
</tr>
</thead>
<tbody>
@foreach([['DE', 'Germany', '19%', '7%', '€19.00'], ['FR', 'France', '20%', '5.5% · 10%', '€20.00'], ['HU', 'Hungary', '27%', '5% · 18%', '€27.00']] as [$iso, $name, $standard, $reduced, $vat])
<tr class="transition-colors hover:bg-surface-subtle">
<td class="pl-5">
<span class="flex items-center gap-2.5 font-semibold text-ink">
<x-ui.flag :iso="$iso" />
{{ $name }}
</span>
</td>
<td class="tabular text-right font-bold text-ink">{{ $standard }}</td>
<td class="tabular text-right text-ink-muted">{{ $reduced }}</td>
<td class="tabular pr-5 text-right text-ink">{{ $vat }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
Breakdown
A dl of label and value pairs
- Net amount
- €1,250.00
- VAT 19%
- + €237.50
- Gross amount
- €1,487.50
Blade
<dl class="max-w-sm divide-y divide-line rounded-control border border-line bg-surface text-sm">
<div class="flex items-center justify-between px-4 py-3">
<dt class="text-ink-muted">Net amount</dt>
<dd class="tabular font-semibold text-ink">€1,250.00</dd>
</div>
<div class="flex items-center justify-between px-4 py-3">
<dt class="text-ink-muted">VAT 19%</dt>
<dd class="tabular font-semibold text-action-deep">+ €237.50</dd>
</div>
<div class="flex items-center justify-between px-4 py-3">
<dt class="font-semibold text-ink">Gross amount</dt>
<dd class="tabular font-bold text-ink">€1,487.50</dd>
</div>
</dl>
Meter and map legend
bg-action on bg-action/15; the map ramp swatches
Net 84% VAT 16%
- < 20%
- 20%
- 21–22%
- 23–24%
- ≥ 25%
- No data
Blade
<div class="max-w-md space-y-5">
<div>
<div class="flex h-1.5 overflow-hidden rounded-xs bg-action/15" aria-hidden="true">
<div class="h-full w-[84%] bg-action"></div>
</div>
<p class="mt-1.5 flex justify-between text-xs text-ink-muted">
<span>Net 84%</span>
<span>VAT 16%</span>
</p>
</div>
<ul class="flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-ink-muted" aria-label="Map legend">
@foreach(['< 20%', '20%', '21–22%', '23–24%', '≥ 25%'] as $step => $label)
<li class="inline-flex items-center gap-1.5">
<span class="eu-map-swatch eu-map-swatch-{{ $step }}" aria-hidden="true"></span>
{{ $label }}
</li>
@endforeach
<li class="inline-flex items-center gap-1.5">
<span class="eu-map-swatch eu-map-swatch-none" aria-hidden="true"></span>
No data
</li>
</ul>
</div>
Menus and toasts
Popovers fade in with a 4px drop and close on Escape or an outside click. Toasts confirm quick actions without moving the page.
Menu
Closes on Escape or an outside click
Blade
<div class="min-h-52">
<div class="relative inline-block" x-data="{ open: true }" x-on:click.outside="open = false" x-on:keydown.escape="open = false">
<button type="button" class="app-button-secondary" aria-haspopup="true" aria-controls="sg-language-menu" aria-expanded="true" :aria-expanded="open.toString()" x-on:click="open = !open">
<x-ui.icon name="languages" class="size-4" />
Language
<x-ui.icon name="chevron-down" class="size-4 transition-transform duration-150" x-bind:class="open && 'rotate-180'" />
</button>
<div
id="sg-language-menu"
x-cloak
x-show="open"
x-transition:enter="transition duration-150 ease-out-quint"
x-transition:enter-start="-translate-y-1 opacity-0"
x-transition:leave="transition duration-100 ease-out"
x-transition:leave-end="opacity-0"
class="app-popover absolute left-0 top-full z-20 mt-1.5 w-56 p-1.5"
>
@foreach(['English', 'Deutsch', 'Français'] as $language)
<button type="button" class="flex min-h-10 w-full items-center rounded-control px-2.5 text-left text-sm text-ink hover:bg-surface-muted" x-on:click="open = false">{{ $language }}</button>
@endforeach
</div>
</div>
</div>
Toasts
$store.toasts.push(message, tone)
Blade
<div class="flex flex-wrap items-center gap-3">
<button type="button" class="app-button-secondary" x-data x-on:click="$store.toasts.push('Calculation copied', 'success')">
<x-ui.icon name="check" class="size-4" />
Show a success toast
</button>
<button type="button" class="app-button-secondary" x-data x-on:click="$store.toasts.push('VIES is slow to respond right now', 'info')">
<x-ui.icon name="info" class="size-4" />
Show an info toast
</button>
</div>
Quick search
Opens with ⌘K, Ctrl K or /
Blade
<button type="button" class="app-button-secondary" x-data x-on:click="$store.palette.show()">
<x-ui.icon name="search" class="size-4" />
Open quick search
<kbd class="app-kbd">⌘K</kbd>
</button>
Icons
Lucide paths at a 1.75 stroke, drawn by one component. Select an icon to copy its tag; add a missing icon to components/ui/icon.blade.php in alphabetical order.
<x-ui.icon name="…" class="size-4" />
No icon matches “”. Add it to components/ui/icon.blade.php.
Flags and logo
The mark is a white tile with a navy slash and a gold dot. Flags are local SVGs with a hairline edge, in five sizes; Greece also accepts EL.
Logo
The mark with and without the wordmark
Blade
<div class="flex flex-wrap items-center gap-8">
<span class="inline-flex items-center gap-2.5 text-lg font-bold tracking-[-0.02em] text-white">
<x-ui.logo />
EU VAT Info
</span>
<x-ui.logo class="size-12" />
</div>
Flags
47 countries, sizes xs to xl
Blade
<div class="flex flex-wrap items-end gap-6">
@foreach(['xs', 'sm', 'md', 'lg', 'xl'] as $size)
<span class="flex flex-col items-center gap-2 font-mono text-xs text-ink-muted">
<x-ui.flag iso="de" :size="$size" alt="Germany" />
{{ $size }}
</span>
@endforeach
</div>
Page skeletons
Two page shapes. A tool page opens with a navy hero holding one raised task card; a reference page opens with the white page header band.
Tool page
Calculator, validator, shared result
Blade
<section class="hero-canvas">
<x-hero-backdrop />
<div class="app-container relative pb-12 pt-6 sm:pb-16 sm:pt-8">
<x-site-breadcrumbs :items="[__('ui.nav.vat_calculator') => '']" variant="dark" />
<p class="app-kicker mt-6">…</p>
<h1 class="mt-3 text-4xl font-bold tracking-[-0.03em] text-white sm:text-5xl">…</h1>
<div class="app-surface-raised mx-auto mt-8 max-w-4xl overflow-hidden text-ink">…</div>
</div>
</section>
<div class="app-container space-y-8 py-8 sm:py-10">
<section class="app-surface p-5 sm:p-6">…</section>
</div>
Reference page
Tables, lists and guides
Blade
<x-page-header :title="__('ui.history.title')" :description="__('ui.history.subtitle')" :eyebrow="__('ui.nav.vat_tools')" :breadcrumbs="[__('ui.history.nav') => '']">
<x-slot:actions>…</x-slot:actions>
</x-page-header>
<div class="app-container space-y-8 py-8 sm:py-10">…</div>
Results
Lead with the answer in display type, restate it in one sentence, break it down, then offer actions. Announce changes through a polite live region.
Calculation result
Answer, summary, breakdown and actions
Total (incl. VAT)
€1,487.50
€1,250.00 plus 19% VAT in Germany
- Net amount
- €1,250.00
- VAT 19%
- + €237.50
Blade
<div class="grid max-w-3xl grid-cols-1 overflow-hidden rounded-card border border-line bg-surface md:grid-cols-2">
<div class="p-5">
<p class="text-[0.8125rem] font-semibold text-ink-muted">Total (incl. VAT)</p>
<div class="mt-1 flex items-end justify-between gap-3">
<p class="tabular text-4xl font-bold tracking-[-0.035em] text-ink">€1,487.50</p>
<button type="button" class="app-button-secondary h-9 min-h-9 px-3 text-xs" aria-label="Copy the calculation">
<x-ui.icon name="copy" class="size-3.5" />
Copy
</button>
</div>
<p class="mt-2 text-sm text-ink-muted">€1,250.00 plus 19% VAT in Germany</p>
</div>
<div class="border-t border-line bg-surface-subtle p-5 md:border-l md:border-t-0">
<dl class="divide-y divide-line rounded-control border border-line bg-surface text-sm">
<div class="flex items-center justify-between px-4 py-2.5">
<dt class="text-ink-muted">Net amount</dt>
<dd class="tabular font-semibold text-ink">€1,250.00</dd>
</div>
<div class="flex items-center justify-between px-4 py-2.5">
<dt class="text-ink-muted">VAT 19%</dt>
<dd class="tabular font-semibold text-action-deep">+ €237.50</dd>
</div>
</dl>
<button type="button" class="app-button-primary mt-4 h-10 min-h-10 w-full px-4">
<x-ui.icon name="share" class="size-4" />
Share result
</button>
</div>
</div>
Tiles and rows
Stat tiles summarise, link cards lead to tools, and list rows keep dates, names and figures in fixed columns.
Stat tiles
Status colour only on the icon
- Recorded changes
- 140
- Increases
- 111
- Decreases
- 29
- Scheduled
- 2
Blade
<div class="grid grid-cols-2 gap-3 lg:grid-cols-4">
@foreach([
['history', 'Recorded changes', '140', 'text-action'],
['trending-up', 'Increases', '111', 'text-danger'],
['trending-down', 'Decreases', '29', 'text-success'],
['clock', 'Scheduled', '2', 'text-warning'],
] as [$icon, $label, $value, $tone])
<div class="app-surface flex items-start gap-3 p-4">
<span class="flex size-9 shrink-0 items-center justify-center rounded-control bg-surface-muted {{ $tone }}" aria-hidden="true">
<x-ui.icon :name="$icon" class="size-4" />
</span>
<dl>
<dt class="text-xs font-semibold text-ink-muted">{{ $label }}</dt>
<dd class="tabular mt-0.5 text-2xl font-bold text-ink">{{ $value }}</dd>
</dl>
</div>
@endforeach
</div>
Link cards
An icon well, a title and one line
Blade
<ul class="grid grid-cols-1 gap-3 md:grid-cols-3">
@foreach([
['map', 'VAT map', 'Compare standard rates visually'],
['history', 'VAT history', 'Every recorded rate change'],
['code', 'VAT widget', 'Embed a calculator on your site'],
] as [$icon, $title, $text])
<li>
<a href="#tiles" class="app-surface pressable group flex h-full items-start gap-3 p-4 hover:border-line-strong">
<span class="flex size-9 shrink-0 items-center justify-center rounded-control bg-action-soft text-action" aria-hidden="true">
<x-ui.icon :name="$icon" class="size-4" />
</span>
<span>
<span class="block font-semibold text-ink group-hover:text-action">{{ $title }}</span>
<span class="mt-0.5 block text-sm text-ink-muted">{{ $text }}</span>
</span>
</a>
</li>
@endforeach
</ul>
List rows
Direction as an icon, a word for screen readers and a sign
-
Romania Standard
19% 21% Increase +2 pp -
Estonia Standard
22% 24% Increase +2 pp -
Luxembourg Standard
17% 16% Decrease −1 pp
Blade
<ol class="app-surface divide-y divide-line overflow-hidden">
@foreach([
['2025-08-01', 'RO', 'Romania', 19, 21],
['2025-07-01', 'EE', 'Estonia', 22, 24],
['2023-01-01', 'LU', 'Luxembourg', 17, 16],
] as [$date, $iso, $country, $old, $new])
<li class="flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3.5 sm:flex-nowrap sm:px-5">
<time datetime="{{ $date }}" class="tabular w-24 shrink-0 text-sm text-ink-muted">{{ \Illuminate\Support\Carbon::parse($date)->format('j M Y') }}</time>
<div class="flex min-w-0 flex-1 items-center gap-3">
<x-ui.flag :iso="$iso" size="lg" class="h-5 w-[1.625rem]" />
<p class="flex min-w-0 flex-wrap items-center gap-2">
<span class="truncate font-semibold text-ink">{{ $country }}</span>
<span class="app-badge bg-surface-muted text-ink-muted">Standard</span>
</p>
</div>
<div class="flex shrink-0 items-center gap-3">
<span class="tabular text-sm text-ink-muted">
{{ $old }}%
<x-ui.icon name="arrow-right" class="inline size-3.5 align-[-2px] text-ink-quiet" />
<span class="font-bold text-ink">{{ $new }}%</span>
</span>
<span @class(['tabular inline-flex min-w-16 items-center justify-end gap-0.5 text-xs font-semibold', 'text-danger' => $new > $old, 'text-success' => $new < $old])>
<x-ui.icon :name="$new > $old ? 'trending-up' : 'trending-down'" class="size-3.5" />
<span class="sr-only">{{ $new > $old ? 'Increase' : 'Decrease' }}</span>
{{ $new > $old ? '+' : '−' }}{{ abs($new - $old) }} pp
</span>
</div>
</li>
@endforeach
</ol>
States
Every async control has a loading state, every list an empty state with a next step, and every error says what happened and what to do.
Empty
A next step, not a dead end
No VAT rate changes match these filters
Try another country or rate type.
Blade
<div class="app-surface px-6 py-12 text-center">
<span class="mx-auto flex size-12 items-center justify-center rounded-full bg-surface-muted text-ink-quiet" aria-hidden="true">
<x-ui.icon name="search" class="size-5" />
</span>
<p class="mt-4 font-semibold text-ink">No VAT rate changes match these filters</p>
<p class="mt-1 text-sm text-ink-muted">Try another country or rate type.</p>
<button type="button" class="app-button-secondary mt-5">
<x-ui.icon name="x" class="size-4" />
Clear filters
</button>
</div>
Loading
Spinner plus aria-busy or a disabled button
Checking VIES…
Blade
<div class="flex flex-wrap items-center gap-6">
<p class="flex items-center gap-2 text-sm text-ink-muted">
<span class="block size-4 animate-spin rounded-full border-2 border-action/30 border-t-action" aria-hidden="true"></span>
Checking VIES…
</p>
<button type="button" class="app-button-primary" disabled aria-busy="true">
<span class="block size-4 animate-spin rounded-full border-2 border-white/30 border-t-white" aria-hidden="true"></span>
Validating
</button>
</div>
Success, outage and error
Outages are about the service, never the visitor
Valid VAT number
Registered for intra-EU trade in Germany.
VIES is temporarily unavailable
Showing the last stored result. Try again in a few minutes.
Not a valid VAT number
Check the digits or ask your customer to confirm their registration.
Blade
<div class="max-w-2xl space-y-3">
<div class="flex items-start gap-3 rounded-card border border-success/30 bg-success-soft p-4">
<x-ui.icon name="check-circle" class="mt-0.5 size-5 text-success" />
<div>
<p class="font-semibold text-ink">Valid VAT number</p>
<p class="text-sm text-ink-muted">Registered for intra-EU trade in Germany.</p>
</div>
</div>
<div class="flex items-start gap-3 rounded-card border border-warning/30 bg-warning-soft p-4">
<x-ui.icon name="alert-triangle" class="mt-0.5 size-5 text-warning" />
<div>
<p class="font-semibold text-ink">VIES is temporarily unavailable</p>
<p class="text-sm text-ink-muted">Showing the last stored result. Try again in a few minutes.</p>
</div>
</div>
<div class="flex items-start gap-3 rounded-card border border-danger/30 bg-danger-soft p-4">
<x-ui.icon name="x-circle" class="mt-0.5 size-5 text-danger" />
<div>
<p class="font-semibold text-ink">Not a valid VAT number</p>
<p class="text-sm text-ink-muted">Check the digits or ask your customer to confirm their registration.</p>
</div>
</div>
</div>
Calls to action
A flat navy block with white and outlined buttons. Use one per page, after the content it follows from.
Navy call to action
hero-canvas rounded-panel
Need VAT rate data in your app?
Every EU VAT rate through a free JSON API. No authentication required.
Blade
<div class="hero-canvas rounded-panel p-6 sm:p-8">
<div class="relative flex flex-col gap-5 md:flex-row md:items-center md:justify-between">
<div>
<p class="text-xl font-bold text-white">Need VAT rate data in your app?</p>
<p class="mt-1.5 max-w-xl text-sm leading-6 text-white/80">Every EU VAT rate through a free JSON API. No authentication required.</p>
</div>
<div class="flex shrink-0 flex-wrap gap-3">
<button type="button" class="app-button-inverse">
<x-ui.icon name="code" class="size-4" />
View the API
</button>
<button type="button" class="app-button-on-brand">
<x-ui.icon name="database" class="size-4" />
EU VAT dataset
</button>
</div>
</div>
</div>
For AI agents
Everything on this page is also available in machine-readable form, generated from the same files the site is built from.
Design tokens
Colour, typography, radius, shadow, easing, duration and size tokens in the Design Tokens Community Group format 2025.10, with OKLCH values, hex fallbacks and dark-theme values under $extensions.
https://vat.businesspress.io/styleguide/design.tokens.json
DESIGN.md
The complete guide as Markdown: principles, every token and component recipe, patterns, accessibility rules and the checks the build enforces. Give it to your coding agent before it touches a template.
https://vat.businesspress.io/styleguide.md
Fetch both from a script
Plain HTTP, no key, CORS enabled
Shell
curl -s https://vat.businesspress.io/styleguide/design.tokens.json -o eu-vat-info.tokens.json
curl -s https://vat.businesspress.io/styleguide.md -o DESIGN.md
Quick start for agents
-
Use tokens only. Style with semantic utilities (
bg-surface,text-ink-muted,border-line,bg-action-soft,text-success) and theapp-*classes below. You must not use raw Tailwind palette colours (bg-blue-600),dark:variants, hex/rgb/oklch literals, default Tailwind shadows, blur, or gradient utilities in templates. -
Keep it flat and square. Use the three radius tokens (
rounded-control,rounded-card,rounded-panel). Buttons, chips and badges are rectangles;rounded-fullis only for dots, spinners, avatars and numbered step markers. -
Pick the page skeleton. A tool page opens with a navy hero (
hero-canvas+<x-hero-backdrop />) holding oneapp-surface-raisedcard. A reference page opens with<x-page-header>followed byapp-surfacecards insideapp-container. -
One primary action per view. Use
app-button-primaryfor it,app-button-secondaryorapp-button-ghostfor the rest, andapp-button-inverse/app-button-on-brandon navy. -
Text and numbers. Put every string in
lang/en/ui.phpand all 24 locales. Money goes throughApp\Support\Vat\Money::format, and every column or total of numbers usestabular. -
Before you commit, run
yarn build,php artisan testandvendor/bin/pint --test. The design tests run with the suite.
Rules and checks
The test suite fails the build when a template breaks one of these rules, naming the file, the line and the fix.
The build fails when a template
- uses a raw Tailwind palette colour
-
uses a
dark:variant -
writes a literal colour in a class or
styleattribute -
uses
backdrop-*blur utilities -
uses a gradient utility (
bg-linear-*,bg-radial-*,bg-conic-*,bg-gradient-*) - uses a default Tailwind shadow
-
uses a radius outside the scale (
rounded-md…rounded-3xlor an arbitraryrounded-[…]) -
makes a pill (
rounded-fulltogether with horizontal padding) -
writes an unescaped
@contextoutside a PHP block -
has an
<img>withoutalt -
adds inline
<svg>outside the icon and logo components
It also fails when a token, utility or component class in app.css is missing from DESIGN.md.
Do and don't
-
Do:
bg-surface,text-ink-muted,border-lineDon't:
bg-white,text-gray-500,border-gray-200 -
Do: Let tokens switch the theme
Don't:
dark:bg-slate-800 -
Do:
app-surface,app-popover,app-sticky-barDon't:
bg-white/70 backdrop-blur-xl -
Do: Flat
bg-brandorhero-canvasDon't:
bg-linear-to-r from-blue-600 to-violet-600 -
Do:
shadow-card,shadow-workflow,shadow-floatingDon't:
shadow-lg,shadow-xl, coloured glows -
Do:
rounded-control,rounded-card,rounded-panelDon't:
rounded-xl,rounded-[13px], pill buttons -
Do:
app-badge bg-success-soft text-successDon't:
rounded-full px-3 bg-green-100 -
Do: Gold as a thin rule or square on navy
Don't: Gold text, gold buttons, gold on white
-
Do:
bg-button text-whiteDon't:
bg-action text-white -
Do:
<x-ui.icon name="copy" />Don't: A new inline
<svg> -
Do: One
app-button-primaryper viewDon't: Several competing primary buttons
-
Do: Status colour plus icon plus text
Don't: Colour alone to show state
BusinessPress
Get a design system like this for your business
Tokens, accessible components and a living styleguide that your team and your AI agents can build with, set up for your brand on the BusinessPress platform.