Skip to content

Barefoot CSS

Conformance demo

Every component below is native HTML, works without JavaScript, and is keyboard-only walkable. Run each walkthrough with nothing but Tab, Enter, Space, Esc.

Theme:

Comparing themes? Every starter renders live, side by side, on the theme gallery.

Conformance matrix

Guarantees are inherited from native elements plus our focus handling. Proven by the walkthroughs below.

Component → WCAG level → keyboard walkthrough
ComponentWCAGKeyboard walkthrough
ButtonAATab to focus → Enter/Space to activate. Focus ring always visible.
Form controlsAATab between controls → type → Tab past. Invalid state announced by browser.
Segmented controlAANative radios: Tab into the group → arrow keys move the selection. Group named by its <legend>.
DatalistAAType → native suggestions appear → arrows pick. The input is a plain text field.
DialogAAFocus moves in; Esc closes; focus returns to trigger. Focus is trapped.
PopoverATab to trigger → Enter opens → Esc or click-away closes.
Dropdown (details)AAEnter toggles → Tab through items. (Esc-close is browser-dependent — use the popover menu for guaranteed Esc.)
AccordionAATab between summaries → Enter toggles. One open at a time via name.
Tabs (opt-in JS)AATab into tablist → / switch, Home/End jump, Tab into the panel. Via js/tabs.js.
CarouselATab to scroller → arrow keys scroll → snaps.
TableAAScreen reader: headers announced via <th>. Hover affordance only.
Sortable tableAAHeader triggers are real <button>s: TabEnter sorts; aria-sort announces the active column and direction. Via js/table-sort.js.
TimelineAAA plain <ol>: reading order is the chronology, nothing interactive.
Empty stateAAReal text content — heading + explanation + action. The glyph is decorative (aria-hidden).
BreadcrumbsAANamed nav landmark; current page announced via aria-current="page".
PaginationAACurrent page is a span, not a link; links announce their labels.
NavigationAANamed nav landmark; Tab through links; current page announced via aria-current="page". Hamburger: toggle carries aria-expanded; Esc closes and restores focus.
LayoutAASidebar splits aside + main and stacks when narrow; .bf-sticky pins while scrolling.
AlertsAArole="alert" announces errors; aria-live="polite" announces updates; dismiss button is a focusable button.
SkeletonAADecorative placeholder — real content must follow; no motion under prefers-reduced-motion.
ToastAArole="status" announces politely; Esc or click-away closes.
ProseAAHeading rhythm and section spacing for long-form content via .bf-prose; nothing hidden, no ordering.
Media & avatarsAAImages carry alt; [data-media] keeps a locked ratio; .bf-avatar is an image, announced like any other.
Avatar groupAAThe overlap is decorative — reading order and announcements are unchanged; every avatar keeps its alt.
SpinnerAADecorative motion stops under prefers-reduced-motion; announce progress with role="status" from your markup.
DividerAAThe label is real text between decorative hairlines — announced like any other content.
Chip / tagAAThe remove control is a real <button> with an aria-label naming what it removes; Tab to it → Enter/Space removes. Without JS nothing hides.

Typography

Heading one

Heading two

Heading three

Heading four

A paragraph. The measure is capped at --bf-content-width (64ch by default) so long text stays readable. Links like this one get a visible underline and offset.

“Barefoot: no boots, no baggage.”
  1. Ordered item
  2. Another item

Ctrl + K for the kbd style, inline code next to it.

/* pre + code */
@layer tokens {
  --bf-primary: #1a1a1a;
}

Divider

A separator with a centered label: add data-divider to any element that can hold text (an <hr> can't — it's void). The label stays real content; the hairlines are decorative pseudo-elements.

Section two

Content continues below the divider, exactly where you'd expect it.

Prose

.bf-prose wraps long-form content and adds heading rhythm and section spacing. The element look — blockquote border, pre, table rows — comes from the base and component layers; the wrapper only imposes the pace. The measure is still capped at --bf-content-width (64ch) so lines stay readable.

Section heading

A paragraph of long-form copy. Prose puts one beat between siblings and a full section gap before headings — each heading opens a new section and sits close to what it introduces.

“No boots, no baggage — but the words still need room to breathe.”

Tables and code keep the rhythm

Headings get a large gap above and a tight gap below; tables and code blocks get their own vertical room.

  • First list item
  • Second list item
  • Third list item
Fluid type steps
TokenHeading
--bf-type-mdh4
--bf-type-lgh3
--bf-type-xlh2
--bf-type-2xlh1

A closing paragraph so the block doesn't end on the table.

Media & avatars

Responsive images scale down to their container (max-width: 100% + height: auto in the base layer). .bf-avatar is a circular image; [data-media] locks a ratio box (16:9 by default, data-ratio for others); .card[data-media] is a thumbnail card whose media bleeds to the top edge.

Avatars

Circular, sized from --bf-avatar-size (2.5rem); data-size="sm|lg" for other sizes. Always alt — an avatar is an image like any other.

Ada Grace Linus

Avatar group

Wrap avatars in .bf-avatar-group to overlap them; a surface ring keeps each face distinct. Purely visual — semantics and reading order are untouched.

Ada Grace Linus Radia

Responsive images

The banner below is 2000px wide; it shrinks to its container and keeps its ratio.

Wide gradient banner that shrinks to its container

Aspect-ratio embeds

[data-media] locks a ratio box on an img, video, iframe, or any element; the width follows the container and the height follows the ratio.

16:9
1:1
21:9

Thumbnail cards

.card[data-media] leads with media that bleeds to the card's top edge; the body below keeps the card padding.

Green field with a lighter circle
Featured story

The media bleeds to the card's top edge; the body keeps the standard card padding.

Buttons

Walkthrough: Tab to a button → Enter or Space presses it. Note the visible focus ring on every stop.

Forms

Preferences
View
40 60% 40% of 100GB

Stepper

A progress tracker for multi-step flows. Native

    semantics; the current step gets aria-current="step". Completed steps are styled from the success token. Horizontal (default) and vertical (data-orientation="vertical") variants.

    Horizontal

    1. 1 Account
    2. 2 Profile
    3. 3 Confirm

    Vertical

    1. 1 Shipping
    2. 2 Payment
    3. 3 Review

Input groups

Wrap an input with a leading icon, currency, or unit in [data-input-group]. The affix shares the input's focus and validation states. Works with input, select, and textarea.

Date, number & email polish

Native pickers and spinners stay; Barefoot themes the surface and ensures validation states apply. Number inputs hide the spinner until hovered/focused; date inputs get a themed calendar button.

Validated on blur — try an invalid address.

Dialog

Native <dialog>. Focus trap + Esc-to-close are built in. The triggers below use the Invoker Commands API (command/commandfor) where the engine supports it — fully declarative opening and closing; engines without it fall back to the same one native line of JS (showModal()). The Popover below is the fully JS-free alternative.

Confirm deletion

This is a native dialog. Try Tab: focus is trapped inside. Press Esc to close.

Popover (zero JS)

The Popover API: a button + popovertarget + a [popover] element. No JavaScript anywhere. In engines with anchor positioning each popover pins to its own trigger automatically — the invoker is the anchor (position-area does the placing, no inline styles needed). To pin a popover to something that isn't its invoker, give that element an anchor-name and point position-anchor at it.

This is a tooltip.

The tooltip is a popover="hint": where the engine has interest invokers it appears on hover and focus and dismisses on hover-away; everywhere else popovertarget keeps click-to-show working, and engines without the hint state treat it as a plain popover. Same markup, three tiers of platform support, zero JS.

Accordion (one-at-a-time)

All details share a name, so the browser allows only one open. For true tabs with arrow-key navigation, see the opt-in tabs below.

Why barefoot?

No boots, no baggage. Tiny, themeable, accessible by default.

Is there any JavaScript?

Zero in the framework. Native elements do the work.

How do I make it mine?

Override a few --bf-* variables. That's it.

Tabs (opt-in JS)

WAI-ARIA tabs with roving tabindex and arrow-key navigation, driven by the opt-in js/tabs.js module (zero dependencies, size in the README table). Try /, Home, End.

Overview panel. Tab from the tablist lands here; arrow keys switch tabs.

Details panel — switch back and forth with the arrow keys.

Pricing panel. Home and End jump to the ends of the tablist.

Reveal (scroll-entry)

data-reveal fades an element up into place as it enters the viewport — a scroll-driven animation (animation-timeline: view()), not an IntersectionObserver, so scrolling back re-hides it. Engines without scroll-driven animations show everything immediately; prefers-reduced-motion turns it off entirely.

Each card rises 1rem…
…and fades from 0 to 1…
…as it scrolls into view.

Container queries

The same [data-grid] markup adapts to its container, not the viewport — wrap it in .bf-contain to make the container. Narrow box below (14rem) → 1 column; full-width → 3 columns. Zero media queries.

Card A

In a 14rem container this grid stays a single column.

Card B

Same markup as the wide one.

Card C

Only the container width differs.

Card A

Wide container → three columns.

Card B

Container queries, not media queries.

Card C

Resize the container, the grid follows.

Responsive table

Add data-table="stack" and wrap the table in a query container (here .bf-contain, same as the grid above): below ~40rem (640px) rows stack as cards and the header row hides. Purely presentational — the <th> headers stay in the DOM, so screen readers still announce them.

Project status
ProjectOwnerStatus
BarefootAdaActive
HeelLinBlocked
LacesKenDone

Sortable table (opt-in JS)

Add data-bf-sort to a table and put a real <button> inside each sortable <th>, then load js/table-sort.js. The module reorders <tbody> rows and maintains aria-sort; numeric columns (Points below) compare numerically, text compares locale-aware. No-JS first: without the module the buttons are inert and the table stays plain but valid.

Sprint tasks
Ship segmented controlAda3
Audit contrast pairsGrace12
Draft migration notesLin5
Regen visual baselinesRadia1

Timeline

Add data-timeline to an ordered list: each entry gets a dot on a connecting spine. The chronology is the native reading order — screen readers hear a plain list.

  1. v3.3 — growth batch

    Segmented control, datalist skin, timeline, empty state, toast stacking, sortable tables, Sunset theme.

  2. v3.2 — deprecation wave

    The first run of the api.md policy: three surfaces announced, once-per-page notices.

  3. v3.1 — platform primitives

    Scroll-driven animations, hint popovers, anchor positioning — all @supports-gated.

Empty state

A class, because there is no native element: .empty-state centers a decorative glyph (aria-hidden — the heading carries the meaning), a muted explanation, and the action out of the emptiness.

No projects yet

Create your first project, or import one from a template.

Cards & badges

Neutral card

A flat card with a thin border and no shadow by default.

NewPrimaryDanger
Lifted card

Add data-lifted to opt into a soft shadow.

Chips (removable tags)

An inline badge with a real <button> inside. Removal is opt-in JS (js/chips.js) — without the module nothing hides, the × just does nothing. Give each remove button an aria-label naming what it removes.

css html accessibility zero-dependencies

Pagination

The current page is a <span aria-current="page">, never a link.

Layout

.bf-sidebar splits a fixed-ish aside (first child, --bf-sidebar-width = 16rem) from fluid content; when the row can't fit the aside plus at least 60% main it stacks to one column. .bf-sticky pins an element to --bf-sticky-top while its scrolling ancestor moves.

Main content

Everything after the first child flows beside it. The main column grows to fill the row; when the row can't fit the sidebar plus at least 60% main, the whole split wraps to a single column — the classic sidebar pattern, zero media queries.

There is no dedicated sticky section to try; the pinned badge above only demonstrates the utility's position: sticky + --bf-sticky-top contract.

Grid variants

[data-grid="auto-fit"] flows as many columns as fit, each at least --bf-grid-min (14rem) — no container query needed, the tracks size against the row itself. data-gap="0|1…8" tunes the gap from the spacing scale.

Auto-fit

As many columns as fit, each ≥ 14rem.

Auto-fit

Resize the row and the flow follows.

Auto-fit

No container queries involved.

Tight

data-gap="2" → 0.5rem gaps.

Tight

Any 1…8 from the spacing scale.

Tight

Same auto-fit flow, tighter rhythm.

Spacing utilities

The full token scale as layout-only helpers: .bf-mt-* / .bf-mb-* (block-start/end margins), .bf-p-* (all-sides padding), .bf-px-* / .bf-py-* (inline/block padding), each mapped to --bf-space-1…8.

mt-6 p-5 px-3 py-2

This box uses bf-mt-6 bf-p-5 bf-px-3 bf-py-2: 2rem of margin above, 0.5rem padding on the block axis and 0.75rem on the inline axis (the axis shorthands win over the all-sides padding).

Alerts & status tokens

Role-aware notices styled from the status tokens. Pick the ARIA role for the semantics — role="alert" for errors, aria-live="polite" for updates — and Barefoot paints it. Dismissible alerts pair a [data-alert-dismiss] button with the opt-in js/alert-dismiss.js module.

Backup completed at 02:00 UTC.

New version available — see the changelog.

You're running low on storage.

Field validation

:user-invalid / :user-valid fire only after a control is touched — nothing flashes before it. Pair with aria-invalid for script-driven forms and aria-describedby for the message.

e.g. you@example.com
Digits and + only.
At least 3 characters.

Spinner

An indeterminate loading indicator, pure CSS: mark any element with data-spinner (data-size="sm|lg" for other sizes). The motion is decorative — it freezes under prefers-reduced-motion, so pair it with text that announces progress; role comes from your markup.

Loading…
Fetching results…

Skeleton

Pure-CSS loading placeholders. Add .skeleton to any element; the shimmer is decorative and static under prefers-reduced-motion. Always replace with real content — the placeholder is never announced.

Toast

A status notice pinned to the bottom edge, built on the Popover API — declarative, JS-free. Toasts are popover="manual": they don't light-dismiss, so an app opens them when work finishes and closes them on a timer or their Close button — exactly how several stay open at once. Use role="status" for non-urgent, role="alert" for urgent. Sibling toasts stack upward: each open one lifts above the open siblings after it (pure CSS; engines without the pieces used simply overlap, newest on top).

Saved successfully.

Uploading assets…