GDPR-compliant cookie banners without dark patterns
Three rules from GDPR Art. 7 and the ePrivacy Directive that most banners break, the EuGH case that pinned the equivalence requirement, and a minimal pattern that satisfies all three.
by Seitenbefund Workshop
A GDPR-compliant cookie banner is not a design problem. It is the
correct application of three requirements: GDPR Art. 7 ("conditions
for consent"), the ePrivacy Directive Art. 5(3), and — for German
audiences — TTDSG §25. If your banner fails any of the three, the
consent it stores is invalid, even if the user clicked "Accept".
In ten audits we will find at least seven of the same pattern:
"Accept all" prominent, "Reject" hidden. This violates the
freely-given requirement. The CJEU ruling in Planet49 (Case
C-673/17, October 2019) made it explicit: choices must be
equivalent. A "Reject" button rendered as a footer link is not
equivalent to a styled primary button.
The close button counts as consent. Common in older
implementations. No active expression of consent means no consent
under GDPR.
Tracking scripts load before the user picks. The banner is
cosmetic. The damage is done. ePrivacy Art. 5(3) only permits
storage without consent if it is "strictly necessary" — analytics
never qualifies.
Three independent obligations, each enforceable on its own:
GDPR Art. 7(2): the request must be presented in an
intelligible and easily accessible form, using clear and plain
language.
GDPR Art. 7(3): withdrawing consent must be as easy as giving
it. If accept is one click, reject must be one click.
ePrivacy Directive Art. 5(3) / TTDSG §25(1): storing or
reading information on the user's device requires prior consent,
except for cookies "strictly necessary" for a service the user
explicitly requested (session, CSRF, language preference).
Schema has 800+ types. Most small business sites need three. Here are the ones that move the needle, the ones we delete in every audit, and the validation steps that matter before shipping.
The three Vitals are field metrics, not lab numbers. Here is what each one captures, why a Lighthouse 100 can still ship a slow site, and the three fixes that move the needle in most audits.
The banner needs three buttons of equal visual weight: "Accept all",
"Essential only", "Customize". No data processing fires before the
user picks.
type Choice = 'all' | 'essential' | 'custom'
export function CookieBanner() {
const [open, setOpen] = useState(true)
const decide = (choice: Choice) => {
setConsentCookie(choice)
if (choice === 'all') loadAnalytics()
setOpen(false)
}
if (!open) return null
return (
<div role="dialog" aria-labelledby="cookie-h">
<h2 id="cookie-h">Cookie settings</h2>
<p>We use cookies for statistical analysis.</p>
<button type="button" onClick={() => decide('essential')}>
Essential only
</button>
<button type="button" onClick={() => decide('custom')}>
Customize
</button>
<button type="button" onClick={() => decide('all')}>
Accept all
</button>
</div>
)
}
Three points are deliberately load-bearing here:
loadAnalytics() runs only after the user clicks "Accept all".
There is no code path that loads scripts before the decision.
The three buttons share DOM order and styling. Rendering "Accept
all" as a <button> and "Reject" as an <a> link would already
fail the equivalence test from Planet49.
role="dialog" plus aria-labelledby lets screen readers
announce the banner as a modal rather than a passing
announcement.
Art. 7(3) says withdrawal must be as easy as giving consent. In
practice that means a permanently visible "Cookie settings" link in
the footer that re-opens the banner.
Server-side IP logs kept for security and operations are permitted
without consent under GDPR Recital 49. The same logs become
analytics — and trigger Art. 5(3) — the moment they are forwarded
to a tracking system.
In eight out of ten privacy audits, banners fail on point 3 from
above: tracking scripts load before consent. The root cause is
almost always the same — Google Tag Manager dropped into <head>
without a consent gate, tags firing on page_view, the banner
showing up afterward as cosmetics. The fix is one conditional in
the GTM loader: consentState === 'all'.
The Seitenbefund short check passively records
which hosts are contacted before consent and lists the offending
requests so you can see the gap before the supervisory authority
does.