Skip to main content Skip to main navigation Skip to footer links Start of main navigation.
Start of main content.

Styleguide & pattern library

A living inventory of the design tokens, type scale, layout primitives and components behind cybmed.com.

Every example on this page is rendered by the site's own stylesheet rather than copied into it. Change something in assets/scss/styles.scss or assets/scss/_variables.scss and it changes here too. Use the sun/moon switch in the navigation to check each pattern in both themes.

Stylesheet styles-min.1.42.css · icon sprite icons.1.2.svg · Raleway v36 · this page is noindex, nofollow.

Design tokens

All tokens are CSS custom properties declared on :root in _variables.scss. The raw palette below never changes between themes; the semantic tokens further down are what actually get remapped in dark mode.

Raw palette

Fixed values. Prefer a semantic token over any of these when styling something new — that is what keeps dark mode working for free.

Three companion forms exist for interpolation: --brand-color-hsl, --theme-color-hsl and --theme-color-rgb (space separated 102 51 153, so it can be used as rgb(var(--theme-color-rgb) / 0.25)). The derived --theme-10 and --theme-25 wrap exactly that.

Dark-mode palette

Only referenced from inside the dark-theme-cvars mixin — never use them directly in a component.

Semantic tokens

These are the ones to reach for. The dot in each row resolves live, so it follows whichever theme you are currently viewing.

TokenLightDarkUsed for
--page-bg-color#fffvar(--dark-page-bg-color)Background of main, and of nav/footer above the l breakpoint
--text-color#3a3a3avar(--dark-text-color)Body copy on body
--banner-colorvar(--theme-color)var(--dark-banner-color)The header band, and the waves that continue it
--banner-textwhitewhiteText sitting on the banner
--footer-colorvar(--theme-color)var(--dark-footer-color)Footer band on narrow viewports
--footer-text-widevar(--banner-color)var(--dark-text-color)Footer text once the footer sits on the page background
--link-colorvar(--brand-color)var(--dark-link-color)Resting link colour
--link-hover-colorvar(--theme-color)var(--brand-color)Hover — note the two themes swap the pair around
--link-visited-colorvar(--firebrick)var(--dark-link-visited-color)Visited links
--link-active-colorvar(--theme-color)var(--dark-link-active-color)Active/pressed links
--text-deco-colorvar(--theme-color)var(--dark-text-color)Icons sitting inside an h2
--hilite-bg-coloralicebluevar(--dark-banner-color).is-hilited band background
--client-bg-strongvar(--theme-25)var(--dark-banner-color)Featured client chips
--client-text-colorvar(--theme-color)var(--dark-text-color)Client chip label
--client-outline-colorvar(--theme-color)var(--dark-text-color)Client chip dashed outline
--header-bg-colorwhite— not remappedhtml background behind everything
--theme-toggle-colorvar(--schoolbusyellow-hsl)— not remappedSun/moon icon; overridden to brand red above l

Dark mode is applied two ways, both feeding the same dark-theme-cvars mixin: [data-theme="dark"] when the visitor has made a choice, and @media (prefers-color-scheme: dark) .no-js as the fallback when the toggle script has not run. Images also drop to opacity: .75 in dark mode and return to full opacity on hover.

Spacing, measure and widths

TokenLightDarkUsed for
--space1.5remVertical rhythm step consumed by .flow
--gutter2remGeneric gutter; also drives --icon-deco-scale
--max-text-width70chMeasure cap on p, h2, h3
--max-content-width48rem768px — narrow content cap
--max-content-lg-width90rem1440px — cap on main, nav, footer
--max-content-xxl-width105rem1680px
--wrapper-inline-padding2remInline padding reference for wrappers

The measure cap is easy to lose track of, so here it is drawn:

This paragraph runs to exactly --max-text-width. Any longer and the line becomes tiring to read; the rule applies automatically to p, h2 and h3, which is why body copy never needs a width of its own.

Fluid scale

Every fluid token is a clamp() generated with Utopia. The bars below are sized by the token itself — resize the window and they move.

Component-specific clamps also exist and are named after what they drive rather than their range: --header-fluid-32-48, --branding-fluid-16-32, --branding-title-fluid-48-56, --branding-title-fluid-l-48-72, --mission-fluid-20-30, --logo-fluid-8-12, --logo-fluid-8-16, --logo-fluid-56-72, --logo-fluid-64-96.


Typography & links

One typeface throughout: Raleway, self-hosted as woff2 and declared for both --header-font-family and --body-font-family with a sans-serif fallback. The @font-face rules are generated and inlined by snippets/head-fonts-local.inc.php; weights 300, 400 and 900 are preloaded.

Weights

Seven weight tokens are declared, but only five upright cuts and three italics are actually downloaded. Using an unloaded weight gets you a browser-synthesised approximation, not real Raleway.

TokenValueLoadedSample
--font-weight-thin100No — synthesisedBuilding websites
--font-weight-lighter200No — synthesisedBuilding websites
--font-weight-light300Yes, + italic, preloadedBuilding websites
--font-weight-normal400Yes, + italic, preloadedBuilding websites
--font-weight-semibold600Yes, + italicBuilding websites
--font-weight-bold800YesBuilding websites
--font-weight-black900Yes, preloadedBuilding websites

Note that --font-weight-bold maps to 800 (ExtraBold), not the usual 700 — 700 is never loaded. --font-weight-heading aliases it, so all headings render at 800.

Sizes

Body text is set once on body and everything else is relative to it.

--font-size-base
1.125rem · 18px
Default body copy. Line height --line-height-base at 1.56.
--font-size-lede
1.3333em · ~24px
Lede paragraphs, set in --font-weight-lede (300).
--font-size-sm
1rem · 16px
Footer .contentinfo columns.
--font-size-xs
0.875rem · 14px
Navigation, language switch, small print, tooltips.

Headings

Headings share --line-height-base: 1.2 (tightened via a :where() override) and the 800 weight. Only h2 and h3 have an explicit size; h1, h4 and h5 fall through to the browser default relative to the 18px base.

Handmade web design

Withstanding the test of time

Fiercely independent

A smaller subdivision

Rarely needed
Markup
<h1>Handmade web design</h1>
<h2>Withstanding the test of time</h2>
<h3>Fiercely independent</h3>
<h4>A smaller subdivision</h4>
<h5>Rarely needed</h5>

The specimen above deliberately renders a real h1 so the scale can be compared, which makes this page the one place on the site carrying two of them — acceptable here since it is noindex internal tooling. Everywhere else: one h1 per page, and on the homepage that h1 is the branding title in the banner, so the page body starts at h2 — see content/all-header.en.inc.php.

Headings with an icon

An h2 containing an svg.deco becomes a flex row and picks up --text-deco-color. No extra class needed — the rule is :where(h2):has(> .deco).

Love

Markup
<h2><svg class="deco" height="1em" width="1.1em" viewBox="0 0 512 512" role="img" aria-label=""><use href="/assets/img/icons.1.2.svg#heart"/></svg> Love</h2>

Body copy, lede and quotes

Cybermedia Concepts is a one man web design studio based in Geneva, Switzerland, with a focus on performance, accessibility and standards.

Simplicity and clarity have become our obsession. Our designs are carefully crafted to meet your needs. Body copy is capped at --max-text-width so the measure stays readable however wide the viewport gets.

— “Trust is the currency of lasting relationships. Built slowly, spent wisely, it turns first-time clients into lifelong allies.”
Markup
<div class="flow">
  <p class="lede">Cybermedia Concepts is a <a href="/about/">one man web design studio</a> based in Geneva, Switzerland, with a focus on performance, accessibility and standards.</p>
  <p>Simplicity and clarity have become our obsession. Our designs are carefully crafted to meet your needs. Body copy is capped at <code>--max-text-width</code> so the measure stays readable however wide the viewport gets.</p>
  <blockquote><em>— &ldquo;Trust is the currency of lasting relationships. Built slowly, spent wisely, it turns first-time clients into lifelong allies.&rdquo;</em></blockquote>
</div>

.lede is normally light (300); inside .page--home it is bumped to semibold, and strong within it goes to 600 rather than 800. blockquote carries no styling of its own beyond the reset — the italic comes from the markup.

Links

Link colour is applied to every a except .plain and .branding--link. Hover and active states swap between brand red and rebeccapurple depending on the theme, so both directions stay distinguishable.

Markup
<ul role="list" class="flow" style="--space: .5rem">
  <li><a href="#typography">A standard link</a> — <code>--link-color</code></li>
  <li><a href="#typography" style="color: var(--link-hover-color)">Hover state</a> — <code>--link-hover-color</code></li>
  <li><a href="#typography" style="color: var(--link-visited-color)">Visited state</a> — <code>--link-visited-color</code></li>
  <li><a href="#typography" class="underline">Explicitly underlined</a> — <code>.underline</code>, 3px offset</li>
  <li><a href="#typography" class="plain">Chrome-free link</a> — <code>.plain</code>, inherits colour, no decoration</li>
  <li><abbr title="A tooltip appears on hover">An abbreviation</abbr> — <code>abbr</code> shows a help cursor</li>
</ul>

The second and third items above are painted with the state token so you can see all four side by side; in real markup you never set these by hand. Inside nav and footer below the l breakpoint the link tokens are locally overridden to white-on-purple.


Layout & breakpoints

Breakpoints

Nine named steps live in the $breakpoints Sass map and are queried through the mq() mixin: @include m.mq(l) for min-width, @include m.mq(l, max) for max-width (which subtracts 0.02px to dodge the fractional-viewport gap). They are also exported as --bp-* custom properties.

NameValuepxWhat changes
xs20rem320Branding stacks horizontally
xss32rem512
s48rem768.wrapper gains 2rem padding; cards go two-up
sl50rem800Container query step for .grid-flow--2
m62rem992Header becomes a row; mission left-aligns
l80rem1280The big one — page becomes a three-column grid
xl90rem1440.is-wide stops bleeding outwards
xxl100rem1600Symmetrical 5vw padding returns; cards go three-up
xxxl128rem2048.is-hilited gains rounded end corners

Page grid

Below l the page is a plain vertical stack: banner, nav, main, footer. At l and above body becomes a CSS grid — a fixed 25vw banner column on the left, a 5rem column holding the vertical wave, and the content column on the right.

grid-template-columns: 25vw | 5rem | 1fr
"header" | "wave" | "navigation"
"header" | "wave" | "main"
"header" | "wave" | "footer"
"debug"  | "debug" | "debug"

Because header spans all three content rows, the banner colour runs the full height of the page on wide screens, and nav and footer switch to the page background (--footer-text is locally reassigned to --footer-text-wide so their text stays legible).

.wrapper

The horizontal container for everything inside main and footer. Below s it has no inline padding and instead caps itself at the measure; from s it takes 2rem padding and releases the cap; from l the padding grows to 4rem.

A section heading

Standard content sits in a wrapper. Nearly every block on the site opens with this exact pair of classes.

Markup
<div class="wrapper flow">
  <h3>A section heading</h3>
  <p>Standard content sits in a wrapper. Nearly every block on the site opens with this exact pair of classes.</p>
</div>

.flow

The owl selector doing all vertical rhythm: .flow > * + * gets margin-block-start: var(--space). Headings raise --space to 2rem before themselves and drop it to 0.5em after, so a heading hugs the text it introduces. Override it inline per block with style="--space: .5rem", as the blog grid and card bodies do.

Tightened flow — --space lowered to .5rem.

The gap above this paragraph is the overridden one.

A heading still gets its 2rem

…and this sits 0.5em under it, because the heading rule wins.

Markup
<div class="flow" style="--space: .5rem">
  <p>Tightened flow — <code>--space</code> lowered to .5rem.</p>
  <p>The gap above this paragraph is the overridden one.</p>
  <h4>A heading still gets its 2rem</h4>
  <p>…and this sits 0.5em under it, because the heading rule wins.</p>
</div>

.is-hilited

A full-bleed tinted band. It cancels the wrapper's inline padding with negative margins, then re-adds it, so the background reaches the viewport edge while the text stays aligned with everything else. The last one on a page gets extra bottom padding to meet the footer wave cleanly.

From the blog

Uses --hilite-bg-color: aliceblue in light mode, the dark banner purple in dark mode.

Markup
<div class="wrapper is-hilited flow">
  <h3>From the blog</h3>
  <p>Uses <code>--hilite-bg-color</code>: aliceblue in light mode, the dark banner purple in dark mode.</p>
</div>

Shown here inside a demo box, so the bleed is clipped. On a real page it runs edge to edge. The .section--blog variant overrides the tint to #e3d8f1 and drops link underlines.

Separators

Three ways to break up a page, in descending strength.



Markup
<hr class="section-rule">
<hr class="row-rule">

.section-rule is a 3px double rule with a centred pilcrow knocked out of it. .row-rule is dotted and hidden by default — it only appears inside a .grid container wide enough to trigger the main-grid container query. Both collapse automatically when adjacent to .is-hilited.

Waves and icon decoration

The banner and footer are joined to the page by SVG waves: .h-wave horizontally below l, .v-wave vertically above it, the footer copy rotated 180°. Both take --banner-color, so they follow the theme. Between sections, snippets/icon-deco.inc.php lays down a shuffled row of sprite icons at 50% opacity with one random “hero” icon in brand red — that is the row just under the table of contents on this page.


Components

Value cards

.grid is a wrapping flexbox that also declares itself a named container (main-grid). Add .gx2 to cap cards at half width from s, and .gx3 to cap them at a third from xxl. .card--special opts a card out of the two-up cap so it can run full width.

Experience

A card marked .card--special keeps the full width of the grid.

Trust

A plain card, capped at half the grid from the s breakpoint.

Green

Cards carry no background or border of their own — only the heading icon and the measure cap.

Markup
<div class="articles grid gx2 gx3">
  <article class="card card--special">
    <h2><svg class="deco" height="1em" width="1.1em" viewBox="0 0 512 512" role="img" aria-label=""><use href="/assets/img/icons.1.2.svg#certificate"/></svg> Experience</h2>
    <p>A card marked <code>.card--special</code> keeps the full width of the grid.</p>
  </article>
  <article class="card">
    <h2><svg class="deco" height="1em" width="1.1em" viewBox="0 0 512 512" role="img" aria-label=""><use href="/assets/img/icons.1.2.svg#handshake"/></svg> Trust</h2>
    <p>A plain card, capped at half the grid from the <code>s</code> breakpoint.</p>
  </article>
  <article class="card">
    <h2><svg class="deco" height="1em" width="1.1em" viewBox="0 0 512 512" role="img" aria-label=""><use href="/assets/img/icons.1.2.svg#leaf"/></svg> Green</h2>
    <p>Cards carry no background or border of their own — only the heading icon and the measure cap.</p>
  </article>
</div>

Outlined card

The one card variant with a surface: a translucent theme-purple fill and a heavier translucent outline. In light mode its text and links are forced to --theme-color.

Ready to start?

Built from rgb(var(--theme-color-rgb) / 0.25) with a 3px outline at 0.5 alpha, 0.5rem radius.

Read the terms and conditions

Markup
<div class="card-outline flow" style="--space: .5rem">
  <h4>Ready to start?</h4>
  <p>Built from <code>rgb(var(--theme-color-rgb) / 0.25)</code> with a 3px outline at 0.5 alpha, 0.5rem radius.</p>
  <p><a href="/tc/">Read the terms and conditions</a></p>
</div>

Blog cards

An auto-filling grid with a 224px minimum track. Cards are populated at request time by promotedPosts(), which pulls a JSON feed from davidroessli.com and caches it for 30 minutes. The whole card is clickable: .card__body--link stretches a pseudo-element over its positioned .card parent.

Markup
<div class="articles blog" style="--space: 2em; --font-weight-heading: var(--font-weight-semibold)">
  <article class="card flow" style="--space: .5rem">
    <div class="card__media">
      <figure></figure>
    </div>
    <div class="card__body flow">
      <h3 class="card__body--h3"><a class="card__body--link" href="#components">An example post title that runs onto two lines</a></h3>
    </div>
    <div class="card__footer card__footer--hashtags">#css #accessibility</div>
  </article>
  <article class="card flow" style="--space: .5rem">
    <div class="card__media">
      <figure></figure>
    </div>
    <div class="card__body flow">
      <h3 class="card__body--h3"><a class="card__body--link" href="#components">A shorter title</a></h3>
    </div>
    <div class="card__footer card__footer--hashtags">#webdev</div>
  </article>
</div>

The figure elements are left empty here to show the :empty placeholder fill — in production they hold a lazily-loaded webp thumbnail derived from the post slug, with rounded top corners.

Client chips

.list-multicol is the generic wrapping chip list; .list-clients is the work-page skin over it, feeding the --client-* tokens in. .is-featured fills the chip and drops the outline; .is-current marks an ongoing relationship.

  • A past client
  • A current client
  • Another one
Markup
<ul class="list-multicol list-clients" style="--justify-content: start" role="list">
  <li class="list-item">A past client</li>
  <li class="list-item is-featured">A featured client</li>
  <li class="list-item is-current"><a class="plain" href="#components">A current client</a></li>
  <li class="list-item is-featured is-current">Featured and current</li>
  <li class="list-item">Another one</li>
</ul>

Alignment is controlled with the --justify-content and --text-align custom properties rather than modifier classes; the work page passes them inline.

Inline lists

Markup
<ul role="list" class="list-inline">
  <li class="list-item list-item--inline"><a href="#components">about</a></li>
  <li class="list-item list-item--inline"><a class="active" aria-current="page" href="#components">work</a></li>
  <li class="list-item list-item--inline">a third item</li>
</ul>

This is the .main-nav pattern. Inside nav it also gets uppercasing, the xs font size, and an underline on the item whose link carries .active (.list-item--inline:has(.active)). Add --around for space-around instead of space-between — that is what the footer social row uses.

Theme toggle

An animated sun/moon SVG. The sun scales up and its beams fade out while a mask circle slides across to carve the crescent, all on Open Props easings and skipped entirely under prefers-reduced-motion. Icon colours come from --icon-fill / --icon-fill-hover, which flip from schoolbus yellow on the purple banner to brand red once the nav sits on the page background at l.

Markup
<button class="theme-toggle" title="Toggles light &amp; dark mode" aria-label="light" aria-live="polite">
  <svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24"><mask class="moon" id="sg-moon-mask"><rect x="0" y="0" width="100%" height="100%" fill="white"></rect><circle cx="24" cy="10" r="6" fill="black"></circle></mask><circle class="sun" cx="12" cy="12" r="6" mask="url(#sg-moon-mask)" fill="currentColor"></circle><g class="sun-beams" stroke="currentColor"><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line></g></svg>
</button>

This copy is inert — the mask id is renamed and the id="theme-toggle" hook is omitted so it cannot clash with the live control in the navigation. The working one is in content/all-theme-toggle.inc.php; the script that drives it is inlined in the head so the theme is set before first paint.

Language switch

Every page defines hreflang-en and hreflang-fr, and the switch simply links to the counterpart. It appears twice: compact in the nav (a globe icon plus a two-letter code) and as a full sentence in the footer, always phrased in the language it takes you to.

Markup
<div class="langswitch"><a class="langswitch--link" href="#components" title="Afficher la version française de cette page"><svg class="deco" width="1em" height="1em" viewBox="0 0 640 640" role="img" aria-label=""><use href="/assets/img/icons.1.2.svg#language"/></svg> Parlez-vous français&nbsp;?</a></div>

Icon sprite

A single cache-busted SVG sprite referenced with <use href="…#id">. Icons inherit currentColor via the reset, and svg { pointer-events: none } keeps clicks falling through to the link underneath. Use .deco for the 1em inline size.

An icon sized to the text it sits in: inline and inheriting colour.

Markup
<p>An icon sized to the text it sits in: <svg class="deco" viewBox="0 0 512 512" role="img" aria-label=""><use href="/assets/img/icons.1.2.svg#mug-hot"/></svg> inline and inheriting colour.</p>

Footer content columns

.contentinfo is a wrapping flex row that goes nowrap from s with each column flexing equally; below that the last column is pushed to full viewport width. .contentinfo--smallprint drops everything inside it to the xs size and lightens its headings to semibold.

Markup
<div class="contentinfo">
  <div class="contentinfo--locate flow">
    <h3>Locate</h3>
    <p>Cybermedia Concepts<br>Avenue Industrielle 14<br>1227 Carouge / Switzerland</p>
  </div>
  <div class="contentinfo--call flow">
    <h3>Reach out</h3>
    <p>Voice <a href="tel:0041223434580">+41 22 343 45 80</a><br>Email <a href="mailto:hello@cybmed.com">hello@cybmed.com</a></p>
  </div>
  <div class="contentinfo--follow flow">
    <h3>Follow</h3>
    <p>Social links sit here in an <code>.list-inline--around</code> row.</p>
  </div>
</div>
<div class="contentinfo--smallprint">
  <h3>The fine print</h3>
  <p>Everything in this block renders at <code>--font-size-xs</code>.</p>
</div>

Tooltips

.has-linktip establishes the positioning context and .linktip is the bubble, hidden until hover. The polaroid on the about page pins its own variant open permanently as a caption.

Markup
<p><a class="plain has-linktip" href="#components">Hover me<span class="linktip">…and a tip appears above</span></a></p>

The polaroid component itself (.polaroid, .vignette-colorful, .frame) is not part of the global stylesheet — it lives in content/about-css.scss and is inlined on the about page only, the same per-page pattern this styleguide uses.


Utilities & accessibility

Hiding things

Four different jobs, four different classes — picking the wrong one is the usual way accessible content gets lost.

ClassVisuallyScreen readersUse for
.sr-onlyHiddenAnnouncedLabels for icon-only controls
.visuallyhiddenHiddenAnnouncedSame, HTML5 Boilerplate flavour; add .focusable to reveal on keyboard focus
.text-assistiveClipped awayAnnouncedSkip links — pair with .display-at-top-on-focus
.hiddenHiddenNot announcedGenuinely removing content (display: none !important)
.invisibleHiddenNot announcedRemoving content but keeping its space

There is a screen-reader-only label right here (this text is only announced, not shown) — inspect the markup to see it.

Markup
<p>There is a screen-reader-only label right here <span class="sr-only">(this text is only announced, not shown)</span> — inspect the markup to see it.</p>

Skip links

Three of them, injected by inc/default-page.php before the nav, the main content and the footer, each labelled from $site["start-of-*"] so they are translated. They are clipped to nothing until focused, then drop an 80px full-width bar across the top of the page with the theme colours inverted.

Markup
<a href="#utilities" class="text-assistive display-at-top-on-focus">Start of main content.</a>

Tab to the link above (it is invisible until it takes focus) to see the reveal. Try it in both themes — the inversion is deliberate.

Floats

.float-end floats to the inline end with a 1rem bottom and inline-start margin. .float-sm-end does the same from the s breakpoint but centres the element on narrower screens instead — that is what the about-page polaroid uses.

Markup
<div class="flow">
  <div class="float-end" style="inline-size: 6rem; block-size: 4rem; background-color: var(--theme-25); border-radius: .25rem"></div>
  <p><code>.float-end</code> floats to the inline end with a 1rem bottom and inline-start margin. <code>.float-sm-end</code> does the same from the <code>s</code> breakpoint but centres the element on narrower screens instead — that is what the about-page polaroid uses.</p>
</div>

Media helpers

ClassDoes
.format--16by925vh tall (min 150px), object-fit: cover, full width; 256px inside .article__hero
.is-wideBleeds an element out past the wrapper padding, stepping back to flush at xl
.u-shadowSoft grey drop shadow on a contained img
.print-onlyHidden on screen, inline-block in print
.clearfixContains floats the old-fashioned way

What the reset gives you for free

Andy Bell's modern reset with Stephanie Eckles' additions, so a few behaviours are already handled and should not be re-implemented per component:

Known quirks

Small things worth knowing before you go looking for a bug that is not there.

Where things live

PathContents
assets/scss/_variables.scssAll tokens, the breakpoint map, the dark-mode mixins
assets/scss/styles.scssLayout, typography, components, utilities
assets/scss/_reset.scssModern reset
assets/scss/_mixins.scssmq(), sr-only
assets/scss/_theme-toggle.scssSun/moon control
content/{page}-css.scssPer-page CSS, inlined in the head — about and this styleguide
content/{page}-main.{lang}.inc.phpPage markup
inc/default-page.phpThe single page template
Start of footer links.