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

Wir antworten in der Regel innerhalb eines Werktages.
Bitte geben Sie eine gültige E-Mail-Adresse an.

Wir verarbeiten Ihre Angaben ausschließlich, um Ihre Anfrage zu beantworten (Art. 6 Abs. 1 lit. b DSGVO), und geben sie an niemanden weiter. Details in der Datenschutzerklärung.

<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>
Required is the default, so the exception carries the mark. On this form only Thema is optional, so marking it is one word instead of four asterisks plus a legend explaining what an asterisk means. The mark is a real word inside the <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.

Wie sollen wir antworten?
Worum geht es?
Mehrfachnennung möglich.
Bitte wählen Sie einen Kanal
Bitte wählen Sie eine der beiden Möglichkeiten.
<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>
The radio's box is square, and the mark is what tells it from a checkbox. Every other system draws a circle here. This one may not: corners are square, and the exceptions the manual grants are the logo, the nav bar and a round avatar — none of which is a control. So single choice and multiple choice are told apart by the mark inside the box rather than by the shape of the box: a tick at 45° for and, the lattice rhombus for or. The rhombus is the system's own unit — the cell every isometric object is cut from, and the same mark .cf-progress fills with light at its head.
Both marks are made of border, not of fill. Forced-colors mode discards 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°.
An invalid group is marked once, on the legend. The answer is wrong, not any one of the five options. A field carries that state on its line; a group has no line, so the legend takes the error colour and the message states it. The obvious drawing — a 2 px red rule down the left of the list — is the one the system bans by name: it is neither an edge nor a division. See Where a line may go.

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.

Rung 2, --presence-near (2-1): here, and not available.
No line at all: a value, not a place to type. It is announced, focusable, copyable, and it submits.
disabledreadonly
Drawingthe same field one rung down the ladder — 2-1 dashed line, same box, same size, same placeno 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
Focusnot focusablefocusable; keeps the global ring, because there is no line left to thicken
Screen readerskippedannounced, with its read-only state
Submitsnoyes
<input class="cf-field__input" type="text" value="…" disabled>
<input class="cf-field__input" type="text" name="kunde" value="CF-2026-0184" readonly>
Rung 2 was reserved and nothing had ever claimed it. --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.
Reach for 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.
Written as [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.
The checkbox and the radio carry it as ink instead, and that is a limit rather than a choice. A box is four edges: CSS gives no control over the dash-to-gap ratio of 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.

<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>
It is rendered by the server, not by a script. The four scripts the system ships are the consent banner, the phone menu disclosure and two optional enhancements — none of them validates anything, so validation belongs on the server: the response to a failed POST contains the summary, the per-field messages and the values the reader already typed. 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

ElementStyle
LabelGeist Mono Medium, 11 px, uppercase, --text-secondary
InputGeist Light, 16 px — large enough that iOS does not zoom on focus
Line1 px underneath; 2 px on focus, no colour change
Placeholder--text-muted — it supplements the label, never replaces it
Hint12 px below the field, always visible
Optional markerthe word (optional) in the label, body face, 12 px
Errorline and text in --feedback-error, plus a plain-language message
Error summary2 px contour in --feedback-error, --space-6 inside, mono title
Field spacing--space-8 (32 px)
Legendthe same mono label a field carries — one rule, two selectors, so the two cannot drift
Choice row20 px square box, 1 px contour, --space-3 to the label; --space-4 between rows
Checkedthe box fills with --gradient-foil; the mark is black and drawn out of border
Checkbox marktwo borders on a 45° rotation — a tick
Radio markthe lattice rhombus: a 10 px square of border turned 45°, 14.1 px across
Disabled fieldline drops to --presence-near (2-1); ink to --text-secondary
Read-only fieldno line; full ink; keeps the global focus ring
Red is the only colour outside the brand palette. Errors need a signal nobody can misread, and lime does not do that job. It is confined to form errors and appears nowhere else in the system. It never stands alone: there is always a message and aria-invalid alongside it.
The red is #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

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-invalid carry 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 and role="radio". The native pair gives you the arrow keys, the wrapping and the single tab stop; the rebuild gives you a roving tabindex to 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.