Components
Forms
No boxes. A field is a line with a mono label above it — the same hairline that separates sections and frames cards.
Contact form
<div class="cf-field"> <label class="cf-field__label" for="f-mail">E-Mail</label> <input class="cf-field__input" id="f-mail" type="email" autocomplete="email" aria-describedby="f-mail-hint" required> <span class="cf-field__hint" id="f-mail-hint">Wir antworten …</span> </div> <!-- optional field: mark the exception, not the rule --> <label class="cf-field__label" for="f-topic"> Thema <span class="cf-field__optional">(optional)</span> </label>
<label>, so it reaches the accessibility tree as
part of the accessible name — required on the control still carries
the state itself.
Choice — one question, several answers
A set of controls that answer one question is a <fieldset> with a
<legend>. That is not decoration: a radio is labelled by its own row,
so a screen reader reading the third row of five has no way to know what the question
was. The legend is announced with every control in the group and supplies it.
role="radiogroup" is the fallback for when the native element cannot be
used; here it can, and the native pair brings the arrow-key model, the wrapping and the
one-tab-stop-per-group behaviour with it.
<fieldset class="cf-choice"> <legend class="cf-choice__legend">Wie sollen wir antworten?</legend> <div class="cf-choice__list"> <label class="cf-radio"> <input class="cf-radio__box" type="radio" name="kanal" value="mail" checked> <span class="cf-radio__label">Per E-Mail</span> </label> </div> <span class="cf-field__hint">Mehrfachnennung möglich.</span> </fieldset> <!-- the same group, checkboxes: swap the type and the two class prefixes. The row, the box and the label are one drawing in both. --> <label class="cf-check"> <input class="cf-check__box" type="checkbox" name="thema" value="fundament"> <span class="cf-check__label">Datenfundament</span> </label>
.cf-progress fills with
light at its head.
background-color and maps border colours into its own palette, so a mark
drawn with a border survives Windows high contrast and a mark drawn with a fill does
not. The rhombus is a zero-sized box with a border on all four sides — which paints a
filled square of twice the border width — turned 45°.
Not available, and not editable
A field is a line, so the two states a field can be in without being wrong are two things that can happen to a line — and the presence ladder already has the rungs for them.
disabled | readonly | |
|---|---|---|
| Drawing | the same field one rung down the ladder — 2-1 dashed line, same box, same size, same place | no line; the mono label with its value under it, which is what .cf-contact already is |
| Ink | --text-secondary, 5.9:1 on CF-Grau | --text-primary — the value is real |
| Focus | not focusable | focusable; keeps the global ring, because there is no line left to thicken |
| Screen reader | skipped | announced, with its read-only state |
| Submits | no | yes |
<input class="cf-field__input" type="text" value="…" disabled> <input class="cf-field__input" type="text" name="kunde" value="CF-2026-0184" readonly>
--presence-near is described on Arrival &
Progress as held for a state that is a claim about the object, not about the
wait. A disabled field is exactly that: it is not late, it is not available. Rung 0,
--presence-absent, stays what it is — a thing that has not arrived yet.
readonly whenever the value still matters.
A disabled control is skipped by assistive technology and does not submit, so a
disabled field showing a reference number hides that number from a screen reader and
drops it from the POST. Read-only keeps both. Disabled is for a control that would be
meaningless to operate — the submit button while a submission is in flight, the
Notwendig row on the consent banner.
[disabled] and [readonly], not
:disabled and :read-only. :read-only
matches every element that is not user-editable — a disabled input is one, and a
<select> is one always, since selects cannot be read-only.
A :read-only rule here would silently strip the line off every select on
the site.
border-style: dashed, so a controlled 2-1 contour has to be a
repeating gradient — and the checked state has already spent this element's background
on the foil. The contour at 0.72 measures 7.30:1 on CF-Grau and 9.23:1 at the white end
of the wash, well over the 3:1 a UI contour owes and exempt from it anyway. The
label keeps full ink at every state: on a consent row the disabled control is
Notwendig, whose copy is the part a supervisory authority would read.
Error summary
When a submission comes back with errors, everything that is wrong is listed once at the top of the form, and every item links to the field it belongs to. Each field also keeps its own message: the summary is the index, not a replacement.
Bitte prüfen Sie zwei Angaben
<div class="cf-error-summary" role="alert" tabindex="-1" id="fehler"> <h3 class="cf-error-summary__title">Bitte prüfen Sie zwei Angaben</h3> <ul class="cf-error-summary__list"> <li><a href="#f-mail">E-Mail: Bitte geben Sie eine gültige E-Mail-Adresse an.</a></li> </ul> </div> <!-- the form posts to its own URL plus the fragment, so the browser lands on the summary by itself when the response comes back --> <form method="post" action="/kontakt#fehler" novalidate>
action carries the #fehler fragment, so the
browser lands on the summary without anything running. role="alert" and
tabindex="-1" are there for the case where a script does inject it into a
page that is already loaded — then it announces itself and can be focused.
Anatomy
| Element | Style |
|---|---|
| Label | Geist Mono Medium, 11 px, uppercase, --text-secondary |
| Input | Geist Light, 16 px — large enough that iOS does not zoom on focus |
| Line | 1 px underneath; 2 px on focus, no colour change |
| Placeholder | --text-muted — it supplements the label, never replaces it |
| Hint | 12 px below the field, always visible |
| Optional marker | the word (optional) in the label, body face, 12 px |
| Error | line and text in --feedback-error, plus a plain-language message |
| Error summary | 2 px contour in --feedback-error, --space-6 inside, mono title |
| Field spacing | --space-8 (32 px) |
| Legend | the same mono label a field carries — one rule, two selectors, so the two cannot drift |
| Choice row | 20 px square box, 1 px contour, --space-3 to the label; --space-4 between rows |
| Checked | the box fills with --gradient-foil; the mark is black and drawn out of border |
| Checkbox mark | two borders on a 45° rotation — a tick |
| Radio mark | the lattice rhombus: a 10 px square of border turned 45°, 14.1 px across |
| Disabled field | line drops to --presence-near (2-1); ink to --text-secondary |
| Read-only field | no line; full ink; keeps the global focus ring |
aria-invalid alongside it.
#A72019, and it used to be #B3261E.
The field error is set at 12 px, which is body text and owes 4.5:1. #B3261E
measures 4.20:1 on CF-Grau — the top of the page wash, which is where a
form on a short page actually sits — and cleared the bar only against the white end of
the wash. #A72019 is the same hue two steps darker: 4.71:1 on CF-Grau,
7.33:1 on white. It is a token now rather than a literal in two rules, for the same
reason every surface value is one, and the inverse theme lightens it to
#F2B8B5 because the dark red reads at 2.9:1 on the footer's black —
under the 4.5:1 it owes. Lightened, it is 12.3:1 there.
Accessibility & data protection
- Every field has a visible
<label>withfor. Never placeholder-as-label. - Errors via
aria-invalid="true"andaria-describedbypointing at the message. On a choice group both go on every control in the group, not on the fieldset. -
Every set of radios or checkboxes answering one question sits in a
<fieldset>with a<legend>. Without it the question is only in the visual layout, and the visual layout is not read out. -
Both controls are real
<input>elements withappearance: none. Only the paint is taken away — the role, the state and the whole keyboard model stay, so nothing has to be reimplemented in ARIA and there is no rovingtabindexto get wrong. -
Prefer
readonlyoverdisabledwhenever the value still matters: a disabled control is skipped by assistive technology and does not submit. - Validate on blur, not on every keystroke.
- After submit, move focus to the confirmation or to the error summary.
-
autocompleteon every field that asks for something about the reader —name,email,organization,tel. This is WCAG 1.3.5, an AA criterion, not a convenience. - Only what is needed is a required field: name, mail, message. Everything else is marked optional and can stay empty.
- Spam protection by honeypot and timing; no third-party captcha service.
The privacy notice, and why there is no checkbox
A short Art. 13 notice — purpose, legal basis, a link to the full statement — sits above the submit button, so it is read before sending rather than after. There is deliberately no consent checkbox: for a plain contact request the legal basis is Art. 6 (1) (b) DSGVO, pre-contractual measures, and a tick box would stage a choice that does not exist. It would also be the wrong thing to rely on, because consent can be withdrawn and the answer to a question cannot be unsent. A checkbox belongs on the newsletter, not here.
Two more things the form owes and the markup cannot show: it has to post over HTTPS (Art. 32), and it may only ask for what answering actually requires (Art. 5 (1) (c)) — which is why the phone number is not on it at all.
Honeypot
A field named the way a bot expects, hidden with the hidden attribute so no
screen reader announces it, no Tab reaches it and no autofill touches it. A
bot reading the HTML fills it anyway, and the server drops that submission without a
word. The alternative is a third-party captcha, which would mean an external request on
every visit and therefore a consent question — a worse trade for the reader and a worse
one legally.
<div hidden aria-hidden="true"> <label for="f-website">Website</label> <input id="f-website" name="website" type="text" tabindex="-1" autocomplete="off"> </div>
Do & don't
Do
- List every error once at the top and again at its field.
- Say what to do — „Bitte geben Sie eine gültige E-Mail-Adresse an“, not „ungültig“.
- Return the reader's own values in the response to a failed submission.
- Keep the hint visible. A hint that appears only on focus cannot be read by anyone still planning their answer.
- Give every choice group a legend, even when the layout makes the question obvious.
- Pre-select the safest radio when there is one, so the group starts in a state the reader can leave rather than one they must enter.
Don't
- Don't put the label in the placeholder. It disappears exactly when it is needed.
- Don't mark an error with colour alone — the message and
aria-invalidcarry it. - Don't validate while the reader is still typing; wait for blur.
- Don't ask for a phone number, a company or a salutation in order to answer a question.
- Don't build a radio group out of
<div>s androle="radio". The native pair gives you the arrow keys, the wrapping and the single tab stop; the rebuild gives you a rovingtabindexto maintain. - Don't round the radio's box. The mark tells single choice from multiple, and corners in this system are square.
- Don't disable a field to show a value the reader needs. That hides it from a screen reader and drops it from the submission — use
readonly.