Saltar para o conteúdo principal
IVA na UE

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

A referência do sistema de design é publicada em inglês.

Principles

Seven decisions every page follows. They explain the rules further down and settle anything the rules do not cover.

North star

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.
  1. 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.

  2. 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.

  3. Numbers are the hero

    Results use the largest type on the view, tabular figures and a plain-language summary line.

  4. Square geometry

    4px controls, 6px cards, 8px panels. Rectangles read as precise and institutional; pills read as playful.

  5. Honest states

    Loading, empty, error and "service unavailable" states are designed, worded and announced; nothing fails silently.

  6. 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.

  7. 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 button fills

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.

Light
Dark

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 Light Dark
Body text ink on surface 17.7:1 AAA 16.4:1 AAA
Supporting text ink-muted on surface 7.8:1 AAA 9.7:1 AAA
Meta and placeholders ink-quiet on surface 5.3:1 AA 6.5:1 AA
Links and selection action on surface 6.5:1 AA 8.4:1 AAA
Selected chip action-deep on action-soft 7.7:1 AAA 8.7:1 AAA
Success success on success-soft 5.1:1 AA 7.8:1 AAA
Warning warning on warning-soft 5.1:1 AA 8.1:1 AAA
Danger danger on danger-soft 5.4:1 AA 6.2:1 AA
Button label white on button 6.5:1 AA 6.2:1 AA
Text on navy white on brand 13.8:1 AAA 16.8:1 AAA
Gold on navy 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-control 4px

    Buttons, fields, chips, segmented track, menu rows, icon wells, notes

  • rounded-card 6px

    app-surface cards, popovers, menus, code blocks, brand panels

  • rounded-panel 8px

    The primary task card (app-surface-raised), the command palette, navy CTA blocks

  • rounded-xs 2px

    Flags, badges and bar ends

  • rounded-full 50%

    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-card

    Resting cards and secondary buttons (built into app-surface)

    1 layer

  • shadow-workflow

    The primary task card on a hero (built into app-surface-raised)

    2 layers

  • shadow-floating

    Menus, 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
Breakpoints
PrefixMin width
sm:640px
md:768px
lg:1024px
xl:1280px
2xl:1536px
Rhythm
WhereClasses
Between sectionspy-8 sm:py-10
Hero bottompb-12 sm:pb-16
Card paddingp-5 sm:p-6
Card gridsgap-4 or gap-6
Reading widthmax-w-[68ch]

Buttons

44px rectangles with pressable feedback. Use one primary button per view, secondary or ghost for the rest, and the inverse and on-brand pair on navy.

Primary, secondary and ghost

app-button-primary is the one main action

Blade
<div class="flex flex-wrap items-center gap-3">
    <button type="button" class="app-button-primary">
        <x-ui.icon name="calculator" class="size-4" />
        Calculate VAT
    </button>
    <button type="button" class="app-button-secondary">
        <x-ui.icon name="share" class="size-4" />
        Share result
    </button>
    <button type="button" class="app-button-ghost">Reset</button>
</div>

On navy

app-button-inverse and app-button-on-brand

Blade
<div class="flex flex-wrap items-center 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>

Sizes and icon-only

Size tweaks are extra classes; icon-only buttons need aria-label

Blade
<div class="flex flex-wrap items-center gap-3">
    <button type="button" class="app-button-secondary">Default · 44px</button>
    <button type="button" class="app-button-secondary h-10 min-h-10 px-4">Compact · 40px</button>
    <button type="button" class="app-button-secondary h-9 min-h-9 px-3 text-xs">
        <x-ui.icon name="copy" class="size-3.5" />
        Small · 36px
    </button>
    <button type="button" class="app-button-ghost size-11 min-h-11 p-0" aria-label="Close">
        <x-ui.icon name="x" class="size-5" />
    </button>
</div>

Loading and disabled

Select Validate to see the busy state

Blade
<div class="flex flex-wrap items-center gap-3" x-data="{ busy: false }">
    <button
        type="button"
        class="app-button-primary"
        aria-busy="false"
        :aria-busy="busy.toString()"
        :disabled="busy"
        x-on:click="busy = true; setTimeout(() => busy = false, 1800)"
    >
        <span x-cloak x-show="busy" class="block size-4 animate-spin rounded-full border-2 border-white/30 border-t-white" aria-hidden="true"></span>
        <x-ui.icon name="shield-check" class="size-4" x-show="!busy" />
        <span x-text="busy ? 'Checking VIES…' : 'Validate'">Validate</span>
    </button>
    <button type="button" class="app-button-primary" disabled>Disabled</button>
</div>

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

2026 rates Standard Valid Scheduled Invalid
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&amp;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
Germany 19% 7% €19.00
France 20% 5.5% · 10% €20.00
Hungary 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

EU VAT Info
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

Germany xs Germany sm Germany md Germany lg Germany 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

  1. Romania Standard

    19% 21% Increase +2 pp
  2. Estonia Standard

    22% 24% Increase +2 pp
  3. 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
Download

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
Open

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

  1. Use tokens only. Style with semantic utilities (bg-surface, text-ink-muted, border-line, bg-action-soft, text-success) and the app-* 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.
  2. Keep it flat and square. Use the three radius tokens (rounded-control, rounded-card, rounded-panel). Buttons, chips and badges are rectangles; rounded-full is only for dots, spinners, avatars and numbered step markers.
  3. Pick the page skeleton. A tool page opens with a navy hero (hero-canvas + <x-hero-backdrop />) holding one app-surface-raised card. A reference page opens with <x-page-header> followed by app-surface cards inside app-container.
  4. One primary action per view. Use app-button-primary for it, app-button-secondary or app-button-ghost for the rest, and app-button-inverse / app-button-on-brand on navy.
  5. Text and numbers. Put every string in lang/en/ui.php and all 24 locales. Money goes through App\Support\Vat\Money::format, and every column or total of numbers uses tabular.
  6. Before you commit, run yarn build, php artisan test and vendor/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

  1. uses a raw Tailwind palette colour
  2. uses a dark: variant
  3. writes a literal colour in a class or style attribute
  4. uses backdrop-* blur utilities
  5. uses a gradient utility (bg-linear-*, bg-radial-*, bg-conic-*, bg-gradient-*)
  6. uses a default Tailwind shadow
  7. uses a radius outside the scale (rounded-md … rounded-3xl or an arbitrary rounded-[…])
  8. makes a pill (rounded-full together with horizontal padding)
  9. writes an unescaped @context outside a PHP block
  10. has an <img> without alt
  11. 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-line

    Don'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-bar

    Don't: bg-white/70 backdrop-blur-xl

  • Do: Flat bg-brand or hero-canvas

    Don't: bg-linear-to-r from-blue-600 to-violet-600

  • Do: shadow-card, shadow-workflow, shadow-floating

    Don't: shadow-lg, shadow-xl, coloured glows

  • Do: rounded-control, rounded-card, rounded-panel

    Don't: rounded-xl, rounded-[13px], pill buttons

  • Do: app-badge bg-success-soft text-success

    Don'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-white

    Don't: bg-action text-white

  • Do: <x-ui.icon name="copy" />

    Don't: A new inline <svg>

  • Do: One app-button-primary per view

    Don'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.

Get yours on BusinessPress