Components
Navigation
One opaque plate, centred, on a full-bleed sheet of frosted glass: the logo at its left, then the seven routes, then the language switch. The glass is the band, not the plate — that is the manual's stacking order, opaque surfaces on top of the frosted layer, and it is what both mockups draw. It is also the only arrangement that holds its contrast, because an opaque plate reads the same over every frame of the hero video underneath it: 10.6:1 for the dim link ink, 21:1 for white.
It was three plates across the full width until the site owner asked for
“alles in einen balken, zentriert” — one bar, centred, logo left, glass
kept. The plate is max-content wide and the band it floats in is
--nav-height exactly.
A plate, not a pill. A rectangle with a 2 px corner.
Default
<!-- in the <head>, after the three stylesheets --> <script src="/design-system/assets/js/cf-nav.js"></script> <nav class="cf-nav" aria-label="Hauptnavigation"> <div class="cf-nav__bar"> <a class="cf-logo" href="/"> <img src="/assets/img/logo/cf-logo-horizontal-white.svg" alt="Control-F"> </a> <button class="cf-nav__toggle" type="button" aria-controls="hauptnav" aria-expanded="false">Menü</button> <ul class="cf-nav__list" id="hauptnav" role="list"> <li><a class="cf-nav__link" href="/" aria-current="page">Home</a></li> … </ul> <a class="cf-nav__lang" href="/en/kontakt" hreflang="en" lang="en" aria-label="Switch to English">EN</a> </div> </nav>
The language switch
Last in the plate, after the seven routes, because it is the one control there that does not move you through the site but between two copies of it. It names where it goes, not where you are — the seven links and their current-page marker already answer “where am I”, and at 375 px a DE|EN pair is 72 px the bar does not have once the lockup, the toggle and the gutters are paid for.
It takes .cf-nav__link's padding, so the switch and the routes are one
row inside the plate; its contour is all that separates a control from a label. It
carried --space-4 / --space-6 when it stood in the band
beside the link plate, which was that plate's padding plus the label's added up, so
that the two plates stood level — there is one plate now and the sum with it. Below
48.75 rem the routes leave the flow and the switch takes
.cf-nav__toggle's metric instead — 44 px, the touch floor.
| Attribute | Why |
|---|---|
href | the same page in the other edition, never the other edition's home. It is the one part of the bar that legitimately moves per page — see scripts/check-nav-parity.py, which normalises it away for exactly that reason |
hreflang | names the language at the other end, so the link is typed rather than guessed from its label |
lang | on the label, because the label is not in the document's language. “EN” inside a German document is read “eh en” by a German synthesiser; this hands the two letters to the English voice |
aria-label | the whole sentence. “EN” read on its own is a letter pair, not an offer |
The head carries the matching pair — <link rel="alternate" hreflang>
for both editions, each naming itself as well as the other, because a crawler
treats an alternate set as complete only when every member of it says so. The
English pages are generated: scripts/build-i18n.py builds
patterns/en/ from the German pattern beside it and replaces only the
words, from design-system/i18n/en.json. It writes both sides of this
control, so the two editions cannot disagree about where the other one is.
Behaviour
| State | Appearance |
|---|---|
| Default | one black plate at --radius-sm, centred in the band, links at white 72 % — 10.6:1 |
| Hover | link goes to white, 120 ms |
| Current page | aria-current="page" → white link on a white 14 % plate |
| Sticky | stays at the top on scroll; over the first 80 px the edge light resolves and the bar becomes a separate plane |
| Below 780 px | links fold under the bar, the toggle button appears — both opaque |
| Menu open | the panel is drawn out from under the bar by a clip edge travelling down it, 480 ms; the toggle's contour lifts to white, 120 ms |
| Esc | closes the panel and puts focus back on the toggle |
| Focus leaves the bar | closes the panel. Focus leaving the document does not — that is the reader going to the browser, not away from the menu |
| Pointer outside the bar | closes the panel |
| A link in the panel | closes the panel. Every link here leaves the page, except one to a fragment on the current page — and a menu left standing over what it has just scrolled to is the failure that costs |
| Widened past 780 px | the open state is dropped, so narrowing again does not drop open a menu nobody asked for |
| No script | the links are a stacked list under a bar that does not stick, and the toggle is not drawn — see The script |
--ease-standard, the same as the
accordion: both are a panel opening, and two panel reveals in one system should not
move on two different curves.
→ Motion
inset(0) is a bug. A clip-path clips everything the
element paints, and the focus ring is painted outside the border box. Measured at
375 px, the first link's ring reaches 3 px above and 3 px left of the panel's own box,
so a clip sitting exactly on that box removes it — 330 lit pixels on the ring's top
stroke against none. 4 px of slop on every side but the travelling edge (2 px of
outline plus the 2 px offset) keeps the whole ring inside the region and leaves the
wipe visually unchanged.
417 × 41 at rx="2" in landing-page-full.svg,
where a pill would be rx="20.5". The implementation carried
--radius-pill here until the vectors were counted — at that height, a
2 px corner in a compressed JPG is indistinguishable from a capsule. The
current-page marker was a capsule inside a capsule, which is what made the whole bar
read as one. → Geometry
--surface-glass-veil (white 4 %), because nothing sits on it but the one
opaque plate — the blur is what you are meant to see, not a wash of white. Sampled from the
mockups, CF-Grau reads 207 above the band and 208 inside it. That also means the band
is invisible over a flat surface, which is correct: on Über uns it does nothing, and
the manual reserves frosted glass for calming complex graphics.
sticky, never fixed. A
backdrop-filter on a fixed element repaints its blurred region on every
scroll frame in iOS Safari. The glass sheet also reaches
--glass-lookahead past the bar's bottom edge and is masked back, so
content arrives through the blur instead of appearing at the boundary already sharp.
The script
assets/js/cf-nav.js is the fourth file in the system that ships, and
like cf-consent.js it creates no markup of its own: the bar, the button
and the panel are ordinary HTML in the page and the file only decides when the panel
is shown. It is ~2 kB, has no dependencies and no build step, and it is the
only implementation of this behaviour.
<script> at the foot of each of the
seven pattern pages — seven byte-identical copies — and an eighth in
assets/js/docs.js, which is documentation chrome and explicitly does not
ship. So the one interactive component every page carries had its behaviour written
down everywhere except in the system: anyone building a page from
the overview's three stylesheets got a menu button that
did nothing, and any fix to the behaviour had to be made eight times.
| Writes | Where | Meaning |
|---|---|---|
data-cf-nav="ready" | <html> | the stylesheet may fold the bar |
aria-expanded | .cf-nav__toggle | the announced state |
data-open | .cf-nav__list | the drawn state |
The last two describe one fact and are written by one function, which is the point: they were set in two places on seven pages, and that is how an announced state and a drawn one drift apart.
<head>, and the placement is
load-bearing rather than a habit. Every rule that folds this bar is gated on
[data-cf-nav], so with scripting off — or the file lost to a dropped
connection, or an error inside it — the five links are a stacked list and the control
that would do nothing is not drawn at all. That fallback is only free if the attribute
is there before the first paint. Written any later, every load below 780 px would show
the open list and then collapse it, and the panel would spend that window in the
layout with its links focusable. A script element in the head runs before the body
exists, which is the whole of the trick.
relative rather than sticky for exactly that reason —
it is a header at the top of the page, not a plane over it.
tokens.css keeps the register of every threshold in the shipping CSS and
states that the register is only worth having if it is complete; a copy in a script is
one the register cannot see. The question the file actually has is not how wide the
viewport is but whether the button is on screen, and the stylesheet already answers
that — so it asks getComputedStyle instead of restating the number.
Accessibility
- The bar lives in a
<nav>; with more than one navigation, each gets anaria-label. - The current page is marked with
aria-current="page", not by colour alone. - The toggle carries
aria-controlsandaria-expandedand updates both — and now looks open too. The attribute flipped while the plate stayed identical, so the announced state and the visible one had drifted apart on the primary control of the whole mobile page.[aria-expanded="true"]takes the contour to white, which is already this component's "on": the hovered link and the current-page link both go to exactly that value. - Not a fill. The bar's current-page marker — white 14 % over black — reads there because it is a small plate inside a larger black field with unlit siblings to compare against. The toggle is itself the plate and has nothing beside it, and white 14 % over black composites to
rgb(36,36,36): 1.3:1 against what it is meant to distinguish itself from, the same near-invisible step the system threw out when it dropped anthracite. Contour before fill settles it. - A disclosure, not a menu. The panel is a
<ul>of links inside a<nav>and carries nomenu,menubarormenuitemrole. Those roles turn the links into an application widget with its own arrow-key model and take the ordinary reading of the list away; the WAI-ARIA Authoring Practices set navigation of this shape as a disclosure, and so does this component. - No focus trap, and that is the pattern rather than an omission.
The panel is not a dialog: it does not make the page inert and it does not lock
scrolling, so Tab walks out of the open panel into the page the way it
walks out of any list of links. A trap is for content that must be dealt with before
anything else, which a menu the reader opened and can close with Esc is
not. Focus never lands inside a closed panel: closed is
display: none, not a clipped or transparent panel still in the tab order. - The toggle is
type="button". A<button>with no type is a submit button, which is inert here today and is one<form>wrapped around a header away from submitting it. - Every page starts with a
.skip-linkto#inhalt, and the<main>it points at carriestabindex="-1". The link is half the mechanism:<main>is not focusable, so without the attribute the fragment moves the viewport and leavesdocument.activeElementon<body>— the reader who pressed the one control built for them is told nothing happened. The ring is kept off the target by its own rule inbase.css; an element out of the tab order is not a control. - Mono uppercase is read normally by screen readers — the casing happens in CSS, not in the markup.
<a class="skip-link" href="#inhalt">Zum Inhalt springen</a> <main id="inhalt" tabindex="-1">
Site structure
The new navigation is deliberately short — five entries, no dropdowns:
| Label | Target | Content |
|---|---|---|
| Home | / | landing page |
| Über uns | /ueber-uns | values, team |
| News | /news | blog archive in the subdivision grid |
| Karriere | /karriere | open roles |
| Kontakt | /kontakt | form, directions |
The topic pages of the old site (Maschinenbau, Energie, Dienstleistungen) have been absorbed into the process sequence on the landing page. Their existing URLs need 301 redirects at relaunch.