Skip to content

Component

footer

Demo

Live demo, isolated in an iframe with only tokens.css, base.css and footer.css. No scripts.

Source

Copy both files, or run npx nojsui add footer.

Source for footer
<!-- <footer> is the contentinfo landmark; a <div> costs you that for nothing.

     Each group's list is named by its own heading through aria-labelledby, so
     it is announced as "Product, list" rather than as an anonymous list among
     three identical ones.

     The row wraps at --sk-footer-group-min. There is no media query in this
     component: it reflows on its own width, so it behaves the same in a
     sidebar as it does across a full page. -->
<footer class="sk-footer">
  <p class="sk-footer__brand">Studio</p>

  <div class="sk-footer__groups">
    <div class="sk-footer__group">
      <h3 class="sk-footer__heading" id="sk-footer-product">Product</h3>
      <ul class="sk-footer__list" aria-labelledby="sk-footer-product">
        <li><a class="sk-footer__link" href="https://nojsui.com/components/">Components</a></li>
        <li><a class="sk-footer__link" href="https://nojsui.com/components/">Playground</a></li>
        <li><a class="sk-footer__link" href="https://nojsui.com/components/">Releases</a></li>
      </ul>
    </div>

    <div class="sk-footer__group">
      <h3 class="sk-footer__heading" id="sk-footer-studio">Studio</h3>
      <ul class="sk-footer__list" aria-labelledby="sk-footer-studio">
        <li><a class="sk-footer__link" href="https://nojsui.com/components/">Work</a></li>
        <li><a class="sk-footer__link" href="https://nojsui.com/components/">About</a></li>
      </ul>
    </div>

    <div class="sk-footer__group">
      <h3 class="sk-footer__heading" id="sk-footer-legal-links">Legal</h3>
      <ul class="sk-footer__list" aria-labelledby="sk-footer-legal-links">
        <li><a class="sk-footer__link" href="https://nojsui.com/components/">Privacy</a></li>
        <li><a class="sk-footer__link" href="https://nojsui.com/components/">Terms</a></li>
      </ul>
    </div>
  </div>

  <div class="sk-footer__legal">
    <span>© 2026 Studio</span>
    <span>Built with zero JavaScript</span>
  </div>
</footer>
/* .sk-footer — brand, link groups, legal bar.

   RESPONSIVE WITHOUT A MEDIA QUERY. A media query asks how wide the viewport
   is; this component only ever needed to know how wide IT is. The groups are
   a wrapping flex row whose items have a minimum basis, so they reflow when
   they run out of room — in a sidebar, in a narrow column, or on a phone,
   with one length to tune instead of a set of breakpoints. .sk-card argues
   the same thing with container queries; this is the simpler tool for a case
   that does not need them.

   Specs:
   - flex-wrap ............. https://drafts.csswg.org/css-flexbox-1/#flex-wrap-property
   - flex-basis ............ https://drafts.csswg.org/css-flexbox-1/#flex-basis-property */

/* Public theme knobs (ADR 0011). Read, never declared. */
.sk-footer {
  --_bg: var(--sk-footer-bg, var(--sk-color-surface));
  --_fg: var(--sk-footer-fg, var(--sk-color-text));
  --_link-fg: var(--sk-footer-link-fg, var(--sk-color-text-muted));
  --_border-color: var(--sk-footer-border-color, var(--sk-color-border));
  --_group-min: var(--sk-footer-group-min, 10rem);
  --_padding-block: var(--sk-footer-padding-block, var(--sk-space-2xl));
  --_padding-inline: var(--sk-footer-padding-inline, var(--sk-space-lg));

  display: grid;
  gap: var(--sk-space-xl);
  padding-block: var(--_padding-block);
  padding-inline: var(--_padding-inline);
  border-block-start: var(--sk-border-width) solid var(--_border-color);
  background: var(--_bg);
  color: var(--_fg);
}

.sk-footer__brand {
  font-weight: var(--sk-weight-semibold);
  letter-spacing: var(--sk-tracking-tight);
}

/* The wrapping row. Each group may shrink to --_group-min and then the row
   wraps rather than crushing them. */
.sk-footer__groups {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sk-space-xl);
}

.sk-footer__group {
  flex: 1 1 var(--_group-min);
}

.sk-footer__heading {
  margin-block-end: var(--sk-space-xs);
  font-size: var(--sk-text-xs);
  letter-spacing: var(--sk-tracking-wide);
  text-transform: uppercase;
}

.sk-footer__list {
  display: grid;
  gap: var(--sk-space-2xs);
}

.sk-footer__link {
  color: var(--_link-fg);
  font-size: var(--sk-text-sm);
  text-decoration: none;
}

.sk-footer__link:hover {
  color: var(--_fg);
}

.sk-footer__legal {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sk-space-sm);
  justify-content: space-between;
  padding-block-start: var(--sk-space-md);
  border-block-start: var(--sk-border-width) solid var(--_border-color);
  color: var(--_link-fg);
  font-size: var(--sk-text-xs);
}

Browser support

Baseline widely available

Works across current and earlier versions of every major engine.

Per-feature support, generated from web-features 3.35.0
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS

Usage

A page footer with three slots: a brand mark, a row of link groups, and a legal bar. Each group is a heading plus a list of links; the legal bar carries a copyright line and whatever else belongs at the bottom of the page.

<footer class="sk-footer">
  <p class="sk-footer__brand">Studio</p>

  <div class="sk-footer__groups">
    <div class="sk-footer__group">
      <h3 class="sk-footer__heading" id="footer-product">Product</h3>
      <ul class="sk-footer__list" aria-labelledby="footer-product">
        <li><a class="sk-footer__link" href="/components/">Components</a></li>
      </ul>
    </div>
  </div>

  <div class="sk-footer__legal">
    <span>© 2026 Studio</span>
    <span>Built with zero JavaScript</span>
  </div>
</footer>

How it works

Responsive by flex-wrap, not by a media query

.sk-footer__groups is a flex-wrap: wrap row and each .sk-footer__group has a minimum flex basis (--sk-footer-group-min, 10rem by default). When the row runs out of room for another group at that width, it wraps — there is no breakpoint anywhere in this file.

A media query asks how wide the viewport is; a footer only ever needed to know how wide it is. Wrapping at a content-derived minimum means the component behaves the same in a sidebar as it does across a full page, and a consumer tunes one length instead of maintaining a set of breakpoints. .sk-card makes the same argument with container queries — this is the same idea with the simpler tool for a case that does not need them: the groups row never changes its own internal layout at a threshold, it just wraps, and flex-wrap already does that natively.

The root element is a real <footer>, not a <div> with a class on it. That is what makes it the contentinfo landmark — a <div> would cost you that for nothing, and a screen reader user who jumps by landmark would lose the way in to find it.

Each group’s list is named by its heading

.sk-footer__list carries aria-labelledby pointing at the id of the .sk-footer__heading next to it — the same wiring .sk-section uses for its own headings. Without it, three link stacks that all look like “a list of links” are announced as three anonymous lists; with it, each is announced as “Product, list” (or whatever its heading says), so the groups stay distinguishable in a screen reader’s landmark/list navigation. The ids in the demo are prefixed per instance so multiple demos on one page don’t collide.

Keyboard contract

The footer is not a control; every focusable element inside it (the links) keeps ordinary link behaviour and tab order.

KeyBehaviour
TabMoves focus through the links in source order
EnterActivates the focused link

Verified manually in VoiceOver and NVDA: not yet — do this before marking the component done, and change this line when you have.

Accessibility notes

<footer> at the top level of the page (not nested in an <article> or <section>) is exposed as the contentinfo landmark automatically; nothing extra is needed for that.

Each group’s <ul> is named by aria-labelledby on its own heading rather than a generic aria-label, so the visible heading text and the accessible name never drift apart — change the heading and the name updates with it.

The heading level on .sk-footer__heading is the consumer’s to choose, not fixed by the component — pick whatever level continues the page’s own outline. The demo above uses h3; this kit’s own docs site uses h2, because its 404 page has only an h1 above the footer, where an h3 would skip a level and fail axe’s heading-order rule.

The legal bar’s two lines are plain <span> siblings, not a list; there is nothing to enumerate.

Degradation

Flexbox (flex-wrap, flex-basis) is Baseline widely available across every supported engine — there is nothing to degrade. The footer looks and behaves identically from the oldest supported browser onward.

Feature usedBaseline statusBehavior without it
Flexbox (flex-wrap)widely availableN/A — no browser in the support matrix lacks it

Theming

PropertyDefaultControls
--sk-footer-bg--sk-color-surfaceFooter background
--sk-footer-fg--sk-color-textBrand and heading colour
--sk-footer-link-fg--sk-color-text-mutedLink colour at rest
--sk-footer-border-color--sk-color-borderThe top edge and the legal divider
--sk-footer-group-min10remNarrowest a link group gets before the row wraps
--sk-footer-padding-inline--sk-space-lgHorizontal padding. Set it to the page’s own gutter so the footer’s content lines up with the sections above it
--sk-footer-padding-block--sk-space-2xlVertical padding