Design System

The tokens, components and accessible patterns that I used to build this site and in my career.

Table of contents (inline)
  1. Principles
    1. Content fundamentals
    2. Accessibility rules
    3. Iconography
  2. Foundations
    1. Color
    2. Typography
    3. Spacing
    4. Borders and corners
    5. Shadows
    6. Motion
    7. Layers
    8. Breakpoints
  3. Components
    1. Button
    2. Link
    3. Slab
    4. Rule
    5. Label
    6. Underline
    7. Heading
    8. Blockquote
    9. InlineCode
    10. Kbd
    11. Code Block
    12. Chip
    13. Chip List
    14. Post Item
    15. Hotspot
    16. TextField
    17. Dialog
    18. Tooltip
    19. Table of Contents
    20. Skip to Main Content
    21. Language Switcher
    22. Site Header
    23. Site Footer
    24. Icon
  4. Functional components
    1. Link and button
    2. Disclosure
    3. Interactive Card
    4. Form Fields
    5. Select
    6. Combobox with a Tree Popup
    7. Pagination
    8. Progress, meter and steps
    9. Quantity spinbutton

Principles

Hey! I am Mica Avigliano a Frontend, Design System and Accessibility Engineer. The purpose of this page is to showcase my personal and professional work along the years. Accessibility is the first rule of every component so I build everything having in mind the WCAG 2.2 AA, HTML native elements and JavaScript solutions. I also try to follow the best practices of design systems, such as tokens, components and patterns.

Content fundamentals

  • Capital Letters. Sentence case for headings, buttons, links and labels. The page title (post-h1), the mono label, meta and link-mono styles are set uppercase by CSS.
  • Buttons and links. Always carries an descriptive accessible name, explaining their action or destination. In isolation, the purpose or context should be understood alone.
  • Errors. Explain what is wrong and how to fix it, for example: "Enter an email address, such as name@example.com."
  • Internalization. Everything (except this page :P) is written in three languages: Spanish, my native language, English, my second language I like to thing, and Italian, which is a language that I am still learning but I included to force myself to write in this language to exercise.
  • Icons. Icons are lucide, only one emoji on the footer which is hidden from assistive technologies.
  • Numbers and dates. Dates are short and localised ("Sep 23, 2026"), reading time is "7 min read", counters are two digits ("01", "07").

Accessibility rules

  • Target WCAG 2.2 AA. Text is 4.5:1 on its ground (3:1 from 24px, or 18.66px bold); borders, focus rings and icons that carry meaning are 3:1. Each token's note names the grounds it was measured on.
  • Use the native element first: dialog, details, button, a, label. ARIA only where the tag runs out.
  • Every control is at least 44px (min-block-size: 2.75rem) and reachable and operable by keyboard. An icon-only control has a name.
  • Colour is never the only signal: links are underlined, the selected language has a bar and a check, an invalid field has text.
  • Numbers drawn by CSS counters are spoken through the / alt-text slot (content: counter(post) '. ' / ...).
  • Put SkipLink first on every page, and send focus to the page h1 after a route change.

Iconography

I use icons provided by Lucide library. They use 16px beside text, 20px for social links, 24px inside the 56px icon button. They are decorative (aria-hidden="true") next to text; a button that is only an icon carries its name in text hidden from sight.

Foundations

Color

Color: primitives
TokenValueUse it for
white#ffffffText and icons on lilac, lilac-dark and danger fills (6.70:1, 9.21:1, 10.59:1), and the card and popover surface.
paper#ffffffThe page background.
fill#f3f3f5muted. ink reads on it at 16.28:1, ink-soft at 7.78:1, ink-faint at 5.13:1, lilac at 6.05:1.
wash#edebf4Identifies the current nav item, hover on link-brut, table headers, secondary. ink reads on it at 15.29:1, ink-soft at 7.30:1, ink-faint at 4.81:1, lilac at 5.68:1.
line#d7d7dcDividers. 1.43:1 against paper, so it is decoration which means that it does not need to be color contrast compliant. Use border or line-control for anything that carries meaning.
line-controlinkBorder of a form control. Aliases ink, so a control edge reaches 3:1 (non-text contrast) against its background.
ink#16161aText, borders and hard shadows. 18.04:1 on paper. Text on marker is always ink.
ink-soft#4b4b55Secondary text on paper (8.62:1), fill (7.78:1) and wash (7.30:1), AAA on all three, for descriptions, footer copy, captions and field hints. wash is the weakest of the three: the old #55555f was 6.24:1 there, under the 7:1 AAA asks of 14px text.
ink-faint#66666fTertiary text on paper (5.68:1), fill (5.13:1) and wash (4.81:1).
lilac#5e548eFill for primary buttons with white text (6.70:1); text and icons on paper (6.70:1), fill (6.05:1) and wash (5.68:1).
lilac-dark#4a4170Hover and pressed state of lilac, and tinted icon text on wash (7.80:1). 9.21:1 against paper. It is not enough for small text on a tint: on a 10% lilac tint over wash it is 6.81:1, under the 7:1 of AAA, so small text uses lilac-deep.
lilac-deep#3d3459Small text in the accent, which accent-text points to. It stays AAA on a tint of lilac: 11.45:1 on paper, 10.33:1 on fill and 9.70:1 on wash, and still 8.47:1 on a 10% lilac tint over wash and 7.35:1 on the 20% hover tint, the weakest ground there is. white text on it is 11.45:1.
danger#82002aErrors and destructive actions. Text on paper (10.59:1), fill (9.56:1) and wash (8.97:1), and white text on a danger fill (10.59:1). In APCA it is Lc 80 or better on each ground, which is what DevTools asks of 14px text at weight 600. At regular weight that size asks for Lc 100, which no red reaches, so set red text at weight 600 or heavier.
marker#ffe600The highlighter band behind Underline. Only ever behind ink text (14.24:1). It should never be a text colour.
focus-ring-primary#0066ffOuter focus ring. Sits outside the control with a 3px offset, so it is measured against the surface: 4.83:1 on paper, 4.36:1 on fill, 4.09:1 on wash.
focus-ring-soft#a5c8ffInner halo of the focus ring, between the control and focus-ring-primary. Decoration around the 3:1 ring because it is not the indicator of the focus state.
Color: semantic
TokenValueUse it for
backgroundpaperBody copy is foreground.
foregroundinkBody copy and headings on background, card, muted and secondary.
cardwhiteSurface of Slab and cards. Text on it is card-foreground.
card-foregroundinkText on card.
popoverwhiteSurface of menus, listboxes and dialogs. Text on it is popover-foreground.
popover-foregroundinkText on popover.
primaryinkStrongest filled control: the solid ink button. Text on it is primary-foreground.
primary-foregroundwhiteText and icons on primary (18.04:1).
secondarywashCurrent nav item, hover rows, table headers, mark. Text on it is secondary-foreground.
secondary-foregroundinkText on secondary (15.29:1).
mutedfillText on it is foreground or muted-foreground.
muted-foregroundink-softSecondary text on background, card and muted: descriptions, captions, footer copy (7.78:1 or better).
accentlilacThe accent: primary button fill, counters, link underlines, icons. Text on it is accent-foreground; as text it reads on background, muted and secondary (5.68:1 or better). Small text uses accent-text instead, and so does any text set on an accent tint: accent on the 20% tint over secondary is only 4.31:1.
accent-textlilac-deepThe accent for small text: labels and eyebrows under 14px, and the text of a link Chip on its accent tint. lilac-deep: 11.45:1 on background, 10.33:1 on muted and 9.70:1 on secondary; on a 10% accent tint over secondary it is 8.47:1, and 7.35:1 on the 20% hover tint, all AAA.
accent-foregroundwhiteText and icons on accent (6.70:1).
destructivedangerError text and destructive fills. Text on it is destructive-foreground.
destructive-foregroundwhiteText on destructive (10.59:1).
borderinkEvery visible edge: slab, button, header rule. 18.04:1 on background (3:1 required).
inputline-controlBorder of inputs, selects and textareas.
ringinkDefault ring color. The site draws its focus indicator from focus-ring-primary, not from this.

Typography

Typography: families
TokenValue
font-sansui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"
font-monoui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace
font-serifGeorgia, "Iowan Old Style", "Times New Roman", serif
Typography: Headings
StyleValueSampleUse it for
post-h13.9rem / 1.02, 700, -0.035emBLOGPage title, once per page. Set in capitals (text-transform: uppercase). 3.9rem is the desktop size; the site clamps it from 2.1rem at clamp(2.1rem, 5.6vw, 3.9rem).
post-h22.4rem / 1.12, 700, -0.025emWhy naming mattersSection heading, sentence case, with a 3px ink rule under it. Clamps from 1.7rem: clamp(1.7rem, 3.2vw, 2.4rem).
post-h31.7rem / 1.25, 700, -0.015emNaming principlesSubsection, with a 5px accent rail on the inline start. Clamps from 1.4rem: clamp(1.4rem, 2.2vw, 1.7rem).
post-h41.15rem / 1.35, 700, -0.01emCheck a nameThird level, with a 3px accent rail.
post-h51rem / 1.4, 700, -0.005emSpelled for each toolFourth level, with a 2px accent rail.
post-h60.9rem / 1.45, 700NotesFifth level, with a 1px accent rail. Colour is foreground at 88%.
Typography: Text
StyleValueSampleUse it for
body1rem / 1.5, 400Insights, tutorials, and best practices for building accessible web experiences.Body copy and UI text on background, card and muted in foreground.
lead1.25rem / 1.4, 400Learn from real-world examples.Page intro under an h1 (text-xl from sm, text-lg below). In muted-foreground.
small0.875rem / 1.25rem, 400Frontend developer and accessibility engineer.Footer copy, captions and helper text in muted-foreground.
card-title2.25rem / 1.25, 700, -0.02emEverything Is A ListPost title in a list from md. Underlined 3px foreground on hover. 1.5rem below md.
card-title-sm1.5rem / 1.25, 700, -0.02emEverything Is A ListPost title in a list below md.
quote1rem / 1.75, 400, 0.005emThe first thing a screen reader announces is the name.Blockquote: serif italic with a 2px accent rule on the inline start.
Typography: Labels and code
StyleValueSampleUse it for
label0.8125rem / 1.5, 700, 0.08emPUBLISHED POSTSLabel. Set in capitals, in accent-text. Eyebrows, listbox headings, dates.
meta0.7rem / 1.5, 700, 0.14emSEP 23, 2026 · 7 MIN READDate and reading time above a post title. Capitals, accent.
link-mono0.9rem / 1.5, 700, 0.14emREAD MORE →The "read more" link in a post entry. Capitals, a link-brut underline.
code0.875em / 1.5, 600aria-describedbyInline code: 0.875em of its parent, in a accent 12% tint with a 2px edge and radius-code.
pre1rem / 1.6, 500const label = "Close dialog"Code block on ink with paper text. 16px at weight 500, the size and weight that make Lc 80 the APCA minimum; the syntax colors are Lc 80 or better on ink. See CodeBlock.

Spacing

Spacing
TokenValueLengthUse it for
space-10.25remIcons with text
space-20.5remGap between chips and inside tight rows (gap-2).
space-30.75remGap in meta rows (gap-3); padding of a compact slab (p-3).
space-41remPage gutter on a phone (px-4); gap in a post entry (gap-4); button inline padding.
space-61.5remPage gutter from the sm breakpoint (px-6); padding of a slab and a dialog (p-6); inset of the back-to-top button.
space-82remGap between footer columns (gap-8); roomy panel padding (p-8).
space-102.5remVertical rhythm of a post entry (py-10); padding of a large slab (p-10).
space-123remVertical rhythm of a post entry from md (md:py-12).

Borders and corners

Borders and corners: radius
TokenValueSampleUse it for
radius8pxThe only corner: every Tailwind radius step collapses to it, rounded-full included. Buttons, slabs, chips, dialogs, inputs.
radius-code4pxInline code and kbd-sized marks, the one place the corner is tighter.
Borders and corners: width
TokenValueSampleUse it for
bw2pxDefault line: inputs, code, tables, the table-of-contents rule.
bw-thick3pxEdge of a slab, a button and the header rule: the signature heavy line.
focus-ring-width4pxWidth of the outer focus ring where it is drawn as an outline.

Shadows

Shadows
TokenSampleUse it for
shadow
box-shadow: 4px 4px 0 0 #16161a;

Shadow

px
px
%
Slab and the table of contents.
shadow-sm
box-shadow: 3px 3px 0 0 #16161a;

Shadow

px
px
%
Button
shadow-pressed
box-shadow: 1px 1px 0 0 #16161a;

Shadow

px
px
%
Hover and press shadow of Button
focus-halo
box-shadow: 0 0 0 3px #a5c8ff, 0 0 0 7px #0066ff;

Shadow

Layer 1
px
px
%
Layer 2
px
px
%
The focus indicator on links, buttons, summaries and inputs: a 3px focus-ring-soft halo, then a 4px focus-ring-primary ring. Replaces the outline.

Motion

Motion
TokenValueUse it for
duration-press80msButton on hover and press.
duration-link120msLink on hover.
duration-fade150msChip on hover, heading-anchor fade, tooltip fade.
duration-menu180msMobile menu and listbox entering.
duration-dialog300msDialog and drawer fade and slide, with @starting-style. Under prefers-reduced-motion every duration is 1ms and every translate is none.

Layers

Layers
TokenValueUse it for
z-raised10A focused link lifts so its ring is not clipped.
z-fixed40Sticky table of contents and the back-to-top button.
z-header50Site header, and the language listbox that opens beneath it.
z-skip-link100The skip link when focused: above everything.

Breakpoints

Breakpoints
TokenValueUse it for
breakpoint-sm40remTailwind sm: gutters go from 1rem to 1.5rem; footer columns appear.
breakpoint-md48remTailwind md: post titles grow to 2.25rem; dialogs may become side drawers.
breakpoint-lg64remTailwind lg: the header shows its full navigation instead of the menu button.
breakpoint-2xl1400pxThe site overrides Tailwind 2xl (96rem) to 1400px.

Components

Each component lists the class or file that implements it on this site or in my career in general. The options named in the text, such as variant and pad, are the design system's vocabulary. Below every component you will find a link to the post where I explain it in detail and a live example of how it should behave.

Button

Actions

Hover a button, tab to it, and press Enter or Space.

The hard-shadowed action: a 3px border edge, radius, shadow-sm and 700 weight. On hover it sinks 2px into its shadow (shadow-pressed, duration-press).

  • Use primary (accent fill, accent-foreground text) for the one action a view exists for, quiet (card) for the rest, and danger (destructive) only for an action that cannot be undone. Say what it does: "Erase account".
  • Verb first, sentence case. Never two primary buttons in one view.
  • If the button is only a visual icon you can use sr-only to hide the accessible name of the button or an aria-label, then it should go along with a tooltip explaining the purpose for sighted users. The icon should have a size="icon" (a 56px square).
  • Targets are at least 44px (min-block-size: 2.75rem). Focus is the focus-halo shadow. Under prefers-reduced-motion the button does not move.
  • A disabled button drops to 50% opacity and keeps its edge.

Related components: Link and button

Link

Actions

They are always underlined, because colour alone never marks a link.

  • brut (default): 700 weight, a 3px foreground underline at a 4px offset.
  • nav: a 3px accent underline. The current page sets current and becomes a filled secondary pill with no underline, and gets aria-current="page".
  • muted: footer-size text in muted-foreground with a 2px accent underline that appears on hover and focus. Hover darkens the text to foreground.
  • Link text should make sense out of context: "Read more about Everything Is A List", not "click here".
  • An icon after the text is decorative, which means it should go to an aria-hidden="true".
  • Focus is the focus-halo; the link also lifts to z-raised so the ring is not clipped.

Related components: Link and button

Slab

Surfaces

Published posts

Native HTML first

Description of the slab content goes here.

Just for clarification: slab is a container component. It uses a 3px border edge, radius and a hard shadow on card. It is used on cards, the table of contents, the language listbox, demo frames and tables are all slabs.

  • shadow (4px) for a slab, shadow-sm (3px) for a control.
  • Choose padding with pad: sm (space-3) for a compact card, md (space-6) by default, lg (space-10) for a feature panel, or none.
  • Give it a landmark or heading when it holds content (<article> with an aria-labelledby).
  • Do not nest a slab inside a slab. Use border 1px or a Rule inside.

Related components: Interactive Card

Rule

Surfaces

Blog


Insights, tutorials, and best practices for building accessible web experiences.

A 3px border line. It ends a header, closes a page intro and separates a heading from a long list.

  • It is a separator, so it is purely decorative aria-hidden="true".

Label

Text

Published postsSelect languageSep 23, 2026

A mono micro-label: 0.8125rem, 700, 0.08em tracking, capitals, in accent-text. It names a region or a value.

  • Type the text in sentence case and let CSS set the capitals to prevent the screen reader to announce letters and announce words.
  • Use it for eyebrows, the listbox heading and counts: "Published posts". Keep it to two or three words.
  • accent-text on background, muted and secondary is 9.70:1 or better (AAA), so it stays readable at 13px. accent alone is 5.68:1 there, which is too thin for small capitals. Do not put the label on accent or on a photo.

Underline

Text

The highlighter: a marker band behind ink text, 0.42em thick and pulled up into the letters.

  • Use it on one phrase per page, such as the hero greeting.
  • The text is always ink (14.24:1 on marker).

Heading

Text

Native HTML first

Native HTML first

Native HTML first

Native HTML first

Native HTML first

Native HTML first

Page headings at six levels. Every level is 700 and tightly tracked (post-h1 to post-h6).

  • post-h1: set in capitals, once per page, clamped from 2.1rem to 3.9rem with 1.02 leading. Not for sentences longer than a line or two.
  • post-h2: sentence case with a 3px border underline
  • post-h3 to post-h6: sentence case with an accent rail on the inline start that thins from 5px to 1px as the level drops

Blockquote

Text

Native HTML first, ARIA only where the tag runs out.

A pull quote: Georgia italic at 1rem with 1.75 leading and a 2px accent rule on the inline start. It is the only serif on the site.

  • Use the HTML tag blockquote and a cite for the source.

InlineCode

Text

The contents list on this page is the live example.

Inline code: 0.875em mono at 600, a 12% accent tint, a 2px edge in accent at 30% and radius-code.

  • Wrap every attribute, element, property and token name in a sentence: aria-describedby, showModal(), --accent.
  • Use CodeBlock for more than a line.

Kbd

Text

Press Tab to move, Enter to open and Esc to close.

A key cap for a keyboard instruction: light, 700 weight, a faint lilac edge and a short inner shadow so it reads as a key.

  • One Kbd per key: Shift then Tab, never one cap for a chord. Use the key's name as it is printed ("Esc", "Enter", "Tab").
  • Ink on the key's near-white face is 16:1 or better.

Code Block

Text

<dialog id="demo" aria-labelledby="demo-title">
  <h2 id="demo-title">A modal dialog</h2>
  <button type="button" command="close" commandfor="demo">
    Close dialog
  </button>
</dialog>

A multi-line code sample: ink ground, paper text, 1rem mono at weight 500 and 1.6 leading, radius and a faint accent edge.

Syntax colors, all on ink (#16161A):

  • Plain text and punctuation: paper or #FFF9F5, 17.29:1 or better.
  • Comments: #FFFFFF in italic, 18.04:1.
  • Attribute names and numbers: #F4D58D, 12.67:1.
  • Functions and types: #B3DFE0, 12.50:1.
  • Tags and keywords: #FFCBBD, 12.48:1.
  • Strings and class names: #DDD2ED, 12.47:1.
  • Invalid code: #FFC8DC, 12.50:1.
  • Pass the code as a string. Long lines scroll inside the block, which takes focus (tabindex="0") so a keyboard user can scroll it.
  • The copy button is an icon-only button: its name comes from copyLabel ("Copy code").
  • Keep it under about 20 lines.

Chip

Content

Both kinds

A pill with a 1px edge in the text colour, 0.875rem at 700. It is either a link or a plain tag.

  • Link (it has an href): accent-text on a 10% accent tint, with its 1px edge in the same colour. Hover deepens the tint to 20%. The text is accent-text, not accent or lilac-dark: on the 20% hover tint over wash, accent falls to 4.31:1 and lilac-dark to 5.91:1, both under the 7:1 AAA asks of 14px text. accent-text (lilac-deep) holds 7.35:1 there, 8.47:1 at rest, and 8.52:1 and 9.92:1 over paper.
  • A link that opens a new page (target="_blank") draws the external-link icon after its text, 16px and aria-hidden, and announces "opens in a new tab" through a hidden text. Give it rel="noopener noreferrer". A link that stays in the tab has no icon.
  • Tag (no href): foreground text on the muted fill with a muted-foreground edge and no hover.

Post Item

Content

  1. 7 minutes read

    Learn how to build a disclosure component using the native HTML details tag

    A step-by-step guide to developing an expandable/collapsible component in a easy and quick way without the need to use JavaScript or WAI-ARIA, just using the HTML tag <details>. Natively, this tag will allow us to create an accessible disclosure element for sreen readers and keyboard. Finally, we are going to learn how to implement transitions with CSS.

    Read more

One entry in the blog index, set inside PostList (an ol whose entries are numbered by a CSS counter). Meta line, linked title, description, then a "Read more" link.

  • The meta line is meta in accent: the date, a square dot, the reading time. Pass the date already formatted for the locale. dateTime is the machine-readable one.
  • The number sits in the left gutter (01, 02) and is spoken as "1." through CSS alt text. Do not draw it as a pseudo-element on the list item or as text.
  • The title is a card-title (1.5rem, 2.25rem from md), underlined 3px on hover. The description is muted-foreground.
  • The "Read more" link is link-brut in mono capitals. Give it the post's title in an aria-label so repeated links have distinct names.
  • Entries are separated by a 2px border line, with space-10 (space-12 from md) above and below each.

Hotspot

Content

Markers over an image that open a card about the thing they point at. There are two variants: the expanded card is a link that contains only a short name and description and leads the user to the product details page and a non-modal dialog that holds information organized in a more structured and complex way.

Link variant

The card is just one <a>. The marker is a button.

Keyboard interaction
  • Tab moves from marker to marker. A marker that takes focus opens its card by itself (WCAG 1.4.13), and focus stays on the marker.
  • Tab again moves focus into the card's link while the card stays open. Shift+Tab goes back to the marker, and the card is still open.
  • Tab from the link closes the card and goes on to the next marker, which opens its own card. Only one card is open at a time.
  • Enter or Space on the marker never closes an open card. Enter on the link redirects the user to a different location.
  • Escape closes the card from the marker or from inside it and returns focus to the marker. The card stays closed until the pointer or the focus comes back, or the user interacts with it again by pressing Enter or Space.
Screen reader interaction
  • The list of markers is announced with its name and its count ("Pages in this image"), because it is a ul with an aria-label.
  • On a marker: "Expand to read more about Blog, button, expanded". The name says that pressing it shows more, and the browser adds expanded or collapsed itself from popovertarget.
  • In the card: "Blog, Posts on accessible components, Read more, link".
Pointer or touch interaction
  • Hovering a marker opens its card. Leaving closes it after 150ms, a wait the pointer can cross, so the card can be hovered.
  • On touch a tap on the marker opens the card, a tap on another marker switches to that card, a tap on the card follows the link and a tap outside closes it.
Semantic structure
  • The markers are in the lis of a ul named for what it holds ("Products in this image").
  • The marker is a button named by triggerLabel, with popovertarget pointing at the card and popovertargetaction="show".
  • The card is an a with popover="auto", so the whole card is the link. The browser gives it the top layer, light dismiss, Escape and the return of focus.
  • It holds three p: the name, the description and a last line ("Read more") that says what following the link gives.

Dialog variant

The card is a non-modal dialog opened by a press and it would need a focus management. The marker is a button and the card is a dialog tag.

Keyboard interaction
  • Enter or Space on a marker opens its card, and focus moves to the card's heading.
  • Tab goes to the close button, then to the link if there is one. Shift+Tab goes to the previous interactive element. If it is the first interactive element, it goes to the marker because since it is not a modal dialog element there is no need to have a focus trap.
  • Tab if there is one, the focus goes to the next interactive eleent inside the non-modal dialog. If not, the focus leaves the card and goes to the next marker in the ul
  • Escape closes the card from anywhere inside it and returns focus to the marker. The close button does the same with Enter or Space.
  • Opening one card closes the other.
Screen reader interaction
  • On a marker: "View details for Design System, button, collapsed", and expanded once the card is open.
  • On opening, focus is on the heading so it is going to be announced by the screen reader. The card is named by that heading (aria-labelledby), so it is announced as a dialog called "Design System".
  • Then, in reading order: the detail line, the close button, the paragraph, the list with its count, and the link by its name ("Go to Design System"). The close button is named "Close".
Pointer or touch interaction
  • A press or a tap on the marker toggles the card, and a press or a tap outside closes it (light dismiss).
  • A tall card scrolls inside its own height, so it never covers the marker.
Semantic structure
  • The markers are in the lis of a ul named for what it holds. Each marker is a button named by triggerLabel, with popovertarget pointing at the card.
  • The card is a dialog with popover="auto", named by its heading. The browser handles the top layer, light dismiss, Escape, the return of focus and the expanded state of the marker. The close button is popovertarget with popovertargetaction="hide", so it needs no script.
  • Inside it is a section that holds a header (the heading and the detail on one side, the close button on the other, laid out with flex so nothing is positioned by hand), then the paragraph, the list and an optional link.
  • The heading is tabindex="-1" and takes the focus on open using javascript.

Both variants

  • A card is a popover tied to its marker by anchor positioning. It goes below the marker in the upper half of the image and above it in the lower half, or on the other side when that has more room.
  • It is at most as tall as the room on its side, less its margins, and scrolls inside when it needs more, so it never covers the marker.
  • Position is a percent of the image, to the centre of the marker. prefers-reduced-motion stops the pulse. With six or more markers, add a "View all" link below the image.

TextField

Forms

Mandatory input. It is used only to reply to you.

A text field associated with a label, optional hint, a 2px input edge, radius and the focus-halo on focus.

  • Always a visible label. Mark a required field with required; the asterisk is destructive and hidden from assistive technology because the required attribute is already set on the input.
  • hint and error are linked through aria-describedby. Explain the fix in the error: "Enter an email address, such as name@example.com.", not "Invalid".
  • An invalid field turns its edge destructive and sets aria-invalid. The error text should be always visible until the user addresses it.
  • Minimum height 44px. The edge is input, at least 3:1 against background.

Related components: Form Fields

Dialog

Surfaces

Erase account?

This deletes your posts and cannot be undone.

This is a modal dialog built using the native dialog tag and using its native methods showModal() to open it and close() to close it.

  • title: the accessible name of the dialog comes from the aria-labelledby attribute and receives focus on open. Focus returns to the control that opened it on close.
  • showModal() method: native method of the dialog tag to open it. It makes the page behind inert, and a backdrop.
  • variant="drawer" slides in from position left or right.
  • There should always be an exit mechanism such as a button to "Close dialog".

Keyboard interaction

  • Enter or Space on the button opens the dialog. Focus moves to the title.
  • Tab moves forward through the controls inside the dialog in reading order: the close button first, then the content. From the last control it wraps to the first interactive element inside the dialog.
  • Shift+Tab moves back. From the close button, or from the title, if it is on the first interactive element inside the dialog the focus will be placed on the last interactive element.
  • Escape closes the dialog, and focus returns to the control that opened it.
  • Enter or Space on the close button returns the focus to the opener.
  • The page behind is inert and Tab key never reaches it.

Focus trap

The dialog tag has a native focus trap BUT it considers the interactive UI widgets of the browser part of the focus flow. In my opinion, this is a blocker for keyboard or screen reader users because if a dialog contains 3 interactive elements, they will have to also navigates through the UI interactive elements making their experience worse. In order to avoid this, I implemented a custom focus trap.

Tooltip

Surfaces

A visual hint for interactive elements that are only an icon. It has a 1px white edge and an arrow. It contains the name of the control and it only will be visible when on hover or on focus. The text should not be put twice to avoid redundancy announcement in screen readers.

  • It shows on hover and keyboard focus and closes on Escape.
  • The tooltip itself has aria-hidden="true" because the control should already have an accessible name.

Table of Contents

Navigation

The contents list on this page is the live example.

A navigation landmark listing a page's headings, numbered by CSS counters (1., 1.1.) in order to organize the content of the page.

  • aside is a sticky Slab (18rem, z-fixed) beside long posts and only visible on wide screens.
  • Nest at most three levels. Deeper levels get smaller and quieter (muted-foreground).

Skip to Main Content

Navigation

Press the button, then Tab. The skip link is hidden until it takes focus.

Main content

The first focusable element on every page. It is hidden until it takes focus. When pressed, it jumps from the header to the main content.

  • It is an <a> placed as the first thing in the tab order and points at #main-content (main has id="main-content" and tabindex="-1").
  • It appears only on focus, at z-skip-link, with the focus-halo.

Language Switcher

Navigation

Open it, move with the arrow keys, Home and End, choose with Enter, close with Escape.

The language control is a button that control a listbox to change the language of the page.

  • The trigger announces the current language and what to do to change it ("The current language is English. Click to change language"). It has and aria-haspopup="listbox" to associate it with its listbox and aria-expanded, and is at least 44px.
  • Options are role="option" with aria-selected. The selected one has an accent bar on its inline start, a secondary ground and a check.
  • Each option shows the name in its own language. Set lang on the page when it changes.
  • The listbox heading is a Label: "Select language". Escape closes it and returns focus to the trigger.

Keyboard interaction

  • Enter, Space or ArrowDown on the trigger opens the menu, and focus goes to the selected language.
  • ArrowDown and ArrowUp move between the options and wrap from the last to the first and back. Home and End go to the first and the last interactive elements.
  • Enter or Space on an option chooses it: the menu closes, focus returns to the trigger and the trigger shows the new language.
  • Escape closes the menu and returns focus to the trigger.
  • Shift+Tab from the first option closes the menu and returns focus to the trigger. Tab from the last option closes it and goes on to the next control in the focus flow of the page.
  • Hovering an option moves focus to it, and a press outside the menu closes it.

Screen reader interaction

  • Opening is announced politely, in a live region: "Language menu opened. 3 options available." Choosing a language is announced the same way: "Language changed to Español".

Site Header

Navigation

The wide header moves its current page and opens the language menu. The compact one is the site's header below the lg breakpoint: its menu button opens the same modal sheet, with the page behind it inert.

The site header on desktop view has the main navigation and the language switcher. On mobile view, the main navigation will be grouped in a expandable menu and the language switcher will be visible too unchanged.

  • My name is the home link and it is plain text.
  • wide shows the nav links and compact, below the lg breakpoint, swaps them for an icon-only menu button, named "Open menu", that opens a modal dialog under the header.
  • Place SkipLink before it.

Compact header and its menu

The compact header is the name, the LanguageSwitcher and the menu button in one row. The example opens the same menu the site opens, under its own header and at its width.

  • Enter or Space on the menu button opens the dialog and moves focus to its close button. The button has aria-haspopup="dialog" and aria-expanded.
  • The menu is a modal dialog named "Mobile navigation": a close button, then the links as a numbered list (01 Home to 05 Design System), each in large type with an arrow and named "Go to Blog". The current page has a tint and aria-current="page".
  • Tab and Shift+Tab cycle through the close button and the links without leaving the dialog. Escape, the close button or a press outside closes it, and focus returns to the menu button.
  • Choosing a link closes the menu. The page behind is inert and does not scroll while the menu is open.

Semantic structure

  • The header is a native header element. SkipLink is its first child, an a to #main-content, so the keyboard reaches it first.
  • The name is an a to the home page. It is a link, not a heading or an image.
  • The wide navigation is a nav named "Main navigation" that holds a links. The current page has aria-current="page". Below lg it is display: none, so it leaves the accessibility tree instead of staying as a hidden duplicate of the menu.
  • The language switcher is a button with aria-haspopup="listbox" and aria-expanded. Its options are buttons with role="option" and aria-selected.
  • The compact menu button is a button with aria-haspopup="dialog" and aria-expanded. The menu is a native dialog named by an h2, hidden from sight, through aria-labelledby: "Mobile navigation".
  • Inside the dialog are a nav and an ol with role="list" of a links, each named "Go to Blog". The numbers are aria-hidden, so the name is read once.

Site Footer

Navigation

The site footer contains a brief description of my role, a resume of the page, social links and a webring link are placed here.

  • Text is small in muted-foreground; links are muted, with the accent underline on hover and focus. Social links are icon-only and each carries its name ("LinkedIn", "GitHub") for screen readers.
  • Columns collapse to one on a phone (breakpoint-sm). Padding is space-12 above and space-8 below.
  • The closing line reads "© 2026 Mica. Made with sweat and tears for everyone", followed by a hidden icon from assistive technology.

Semantic structure

  • The footer is a footer element and it is placed inmediately after the main element.
  • Every column has its own heading like an h3 for the name and an h4 for "Navigation" and "Get in touch". The webring has an h2.
  • The email is an a with mailto:. The social links are icon-only a with target="_blank" and rel="noopener noreferrer", named with aria-label ("Go to my Github's profile"), and the icon is aria-hidden. The webring links use rel="external".
  • The VAT number is plain text. Next to it is a button type="button" named "Copy VAT number", and the confirmation goes in a hidden span with role="status", so it is announced without moving focus.
  • The icon is aria-hidden, and the closing note is a p.

Icon

Iconography

import { ArrowUp } from 'lucide-react', then <ArrowUp className="w-6 h-6" aria-hidden="true" />

The icon set comes from lucide-react: a 24px grid, a 2px stroke, round caps and joins, currentColor.

  • Icons next to text are decorative: aria-hidden="true", and the text carries the name. An icon alone sits in a button whose name is its visible or screen-reader text.
  • Size them to the text: 16px beside 14-16px text, 20px for footer social links, 24px inside the 56px icon button.
  • Colour follows the text (currentColor), or accent for icons in the language switcher. Never a second hue.

Functional components

I have created a post for most of the patterns listed here. At the end of each you will find a link to the post and if there is no post, you will find a message explaining I am currently working on it.

Link and button

Built with: a href · button type

A link takes the user somewhere. A button does something in the current page.

The live example is in the post.

How it works

A link redirects the user to a new location: another page, or another section of the current one through a # parameter. It can also download a file. It needs an href to carry the semantics of a link.

A button dispatches an action inside the page: open a modal, play a video, post a comment.

Forcing a link to behave like a button, or a button like a link, goes against the native behaviour of both. If you find yourself doing it, you need the other element.

Keyboard

  • A link is activated with Enter. Space scrolls the page instead.
  • A button is activated with Enter or Space.

Screen readers

  • A link is announced as “Link, [name]” and a button as “Button, [name]”. A descriptive and correct accessible name is the most important thing on either.

Which one to use

  • An expandable section: a <button> with aria-expanded.
  • Jump to a section of the same page: an <a> with href="#section".
  • Load more items into a list: a <button>, then place focus on the first new item.
  • Read more somewhere else: an <a> with an href.
  • Open a menu: a <button> with aria-haspopup and aria-expanded.
  • Play or pause a video: a <button>.
  • Download a file: an <a> with the download attribute.
  • A card that leads to a detail page: a <a> stretched with ::after.

Avoid

  • outline: none without a replacement focus style. Keyboard and screen reader users need a visible focus on every interactive element.

Read the post: Link or Button

Related components: Button, Link

Disclosure

Built with: details · summary · ::details-content

An expandable and collapsible section with no JavaScript and no WAI-ARIA.

How it works

The details element is natively accessible and functional. By pressing Enter or Space on the summary, it expands and/or collapses, Tab moves to the next interactive element inside or after it, and the screen reader announces its name and whether it is collapsed or expanded.

Anatomy: details wraps the whole disclosure. summary is its first direct child and acts as the header and the control. ::marker is the disclosure triangle. ::details-content is the content.

The open attribute expands it by default. The name attribute groups several details so that opening one closes the others.

Keyboard

  • Tab: move focus to the disclosure, or to the next one.
  • Shift + Tab: move focus to the previous one, or to the previous interactive element.
  • Space or Enter: expand or collapse the details.

Screen readers

  • VoiceOver with Safari: “[name], collapsed, summary”. With Chrome or Firefox: “collapsed, disclosure triangle, group”.
  • NVDA with Chrome or Firefox: “[name], button, collapsed”.
  • TalkBack with Chrome: “collapsed, [name], disclosure triangle”.

Styling

  • Replace the marker with ::marker or remove it and put your own icon.
  • Style the content using ::details-content. Do NOT use a div.
  • Do not forget to add prefers-reduced-motion.

Use cases

  • FAQ with one answer open at a time (the name attribute)
  • Inline “read more”
  • Spoiler or show solution
  • Order details
  • Animated markers
  • Collapsible filter panel
  • Specifications
  • Collapsed code and logs
  • Emoji marker

Read the post: Learn how to build a disclosure component using the native HTML details tag

Interactive Card

Built with: article · a · button

How it works

A card groups related content so it has to be tagged by using the HTML tag article.

A simple interactive card has one interactive element with a single purpose: a link stretched over the card with a ::after of position: absolute and inset: 0. It has one tab stop, and focusing it highlights the whole card. Use it for blog and news teasers, category tiles, team member cards and promo cards with one call to action.

A complex interactive card has two or more controls, each with its own purpose. They are siblings not nested and each interactive element has atab stop. It may have a stretched link only if it has one clear destination, such as the product page. It must not have one when it has two calls to action (“Start trial” and “Compare features”) or contains swatches, a quantity stepper or inline links.

Screen readers

  • The article is announced with its accessible name, then the heading with the primary link, then the description, then the other actions.

Avoid

  • tabindex="0" or onclick on the article. It fails 2.1.1 Keyboard and 4.1.2 Name, Role, Value.
  • Wrapping the whole card in a link that holds other interactive elements. It is invalid HTML, and the click bubbles, so “Add to cart” also leaves the page.
  • A div with role="button" as a card. A button cannot semantically be a group container.

WCAG 2.2

  • 1.3.1 Info and Relationships (A): every card is an article named by its heading through aria-labelledby.
  • 2.1.1 Keyboard (A): every action is a native a or button.
  • 2.4.3 Focus Order (A): tab order follows the DOM and the stretched link adds no extra stop.
  • 2.4.4 Link Purpose in Context (A): link text is clear next to the heading.
  • 2.4.7 Focus Visible (AA): the whole card is outlined through :has(:focus-visible).
  • 4.1.2 Name, Role, Value (A): native elements, and aria-pressed on a save toggle.

Read the post: Learn how to build a semantic, accessible and interactive Card UI using HTML and CSS

Related components: Slab

Form Fields

Built with: label · fieldset and legend · select · input · textarea

How it works

Every input is built using its native HTML element such as label, fieldset, legend, select and the type attribute. WAI-ARIA it is used to enrich the accessibility of these elements such as aria-describedby linking to a hint or an error to its field.

Required fields use one convention. The asterisk is aria-hidden, because unhidden most screen readers read it as “asterisk”. The word “required” is announced from the native required attribute.

Field by field

  • Select: a native select tag.
  • Text: autocomplete is per field (given-name, family-name), spellcheck="false" for a name, and maxlength is enforced by the browser.
  • Phone number: type="tel".
  • Password: a button with type="button" and aria-pressed toggles the input type. autocomplete="new-password".
  • Date: type="date" hands the picker and the locale format to the browser.
  • File attachments: the label and input type="file", and drag and drop is an enhancement.
  • Checkboxes: a group that has a fieldset and legend and and one tab stop per checkbox. The checkbox is just input and label.
  • Radio group: a group that has a fieldset and a legend. It is required means at least one is checked, and the error is described once and shared through aria-describedby.
  • Switch: role="switch" on a real checkbox, so its checked state is the state.

Validation

  • aria-invalid is always present, “true” or “false”, never omitted when valid.
  • aria-describedby points at the same paragraph whether the field is valid or not.
  • The red border comes from [aria-invalid="true"].
  • Only the error summary has role="alert".

Avoid

  • A div styled like a select.
  • A placeholder doing a label's job.
  • type="number" on a phone number or a card number.
  • role="alert" on every field's error at once.
  • A required group's error duplicated in every option's aria-describedby.
  • display: none on a disabled file input.

WCAG 2.2

  • 1.3.1 Info and Relationships (A)
  • 1.3.5 Identify Input Purpose (AA)
  • 1.4.3 Contrast (AA)
  • 1.4.11 Non-text Contrast (AA)
  • 2.1.1 Keyboard (A)
  • 2.4.3 Focus Order (A)
  • 2.4.7 Focus Visible (AA)
  • 2.5.8 Target Size, Minimum (AA)

The post is still in progress.

Related components: TextField

Select

Built with: select · option and optgroup · label

The native html `select` tag.

How it works

A native select exposes role combobox, aria-expanded and its listbox through the browser without the need of Javascript or WAI-ARIA.

Decisions

  • Dependent selects: when changing one replaces the options of another, move focus to it once the picker has closed, and only if it was ever open, because arrowing a closed select also fires change. Announce the consequence through aria-describedby and role="status".
  • Validation: the placeholder is a disabled empty option. An empty submit sets aria-invalid, ties the message to the control with aria-describedby and sends focus back to the select.
  • Apply on change is fine when content reorders in place: focus stays on the control and a role="status" count announces the result.
  • One select per row needs its own aria-label that starts with the visible column text (WCAG 2.5.3).
  • Disabled, not hidden: a disabled option stays in the listbox, is skipped by the arrow keys and is announced as unavailable. An option takes no description, so the reason goes in its text.
  • A select gated by a checkbox uses disabled, not aria-disabled, which takes it out of the tab order and out of the submission.
  • Typeahead is native. Past roughly fifteen options consider an editable combobox, but everything hand-rolled has to rebuild all of it first.
  • A choice that makes a new field necessary earns focus on it. Content that merely changed, such as a price or a summary, must not steal it.
  • When the answer may not be on the list, select is the wrong control: it can only return what you enumerated.

The post is still in progress.

Combobox with a Tree Popup

Built with: input role=combobox · role=tree · role=treeitem

A product search whose suggestions each carry a second level of related suggestions.

How it works

A combobox is a role on an input (editable) or a button (select-only) that controls a popup, which can be a listbox, grid or tree. Its one mandatory state is aria-expanded; aria-haspopup, aria-controls, aria-autocomplete and aria-activedescendant are used as the implementation needs.

In the tree popup a treeitem is a direct descendant of the tree. Children go in a role="group", aria-expanded exists only on items that have children and aria-selected marks the current item.

DOM focus should stays always on the input and on the popup we are going to use an aria-activedescendant focus management to to manage a virtual focus on each active item based on the id. When the item is activated and it has a second level, the group expands. Moving real focus into the list would stop the user from typing.

Announcements go through a debounced live region so the screen reader is not interrupted as the user types.

The post is still in progress.

Pagination

Built with: nav · a · aria-current · aria-setsize and aria-posinset

How it works

Focus moves through every interactive element of the pagination with a visible indicator.

Rules

  • Make every page a link. Its href carries the page (?page=3) and the current one has aria-current="page".
  • Following a link updates the address, so Back and Forward, a new tab and a shared link all land on the same page.
  • Group the pagination in a nav with an aria-label that identifies it.
  • Give each item aria-setsize (the total number of items) and aria-posinset (its position). ARIA allows them on the listitem, not on the link itself.
  • Give each item an aria-label that says which page it goes to.
  • Include next and previous links, each with its own label. A link cannot be disabled, so leave them out on the first and the last page.
  • Label an ellipsis.
  • Every time a page loads, announce which page it is and how many items arrived: “Page 3 loaded. Showing 20 items”.

Implementation

  • A reusable hook with a generic type fetches the data from a URL, the current page and a page size.
  • The component takes currentPage and totalPages, an hrefFor function that builds the address of each link, and onNavigate for a plain click.
  • getPageNumbers keeps five centred items, with an ellipsis after the fourth item and before the last three.

The post is still in progress.

Progress, meter and steps

Built with: progress · meter

Avoid implementing a soup of divs to built progress indicators because we have their HTML semantics equivalents!!.

How it works

The progress element represents the completion of a task. max is how much work the task needs in total, value is how much is done, and if it is not specified, the value is indeterminate. Its anatomy is a label that says what is progressing, the numeric value, the coloured completed portion and the remaining portion.

The meter element represents a scalar value within a known range or a fractional value, such as disk usage, a data plan or password strength. It takes value, min, max, low, high and optimum.

A step indicator shows progress through a process. It should have between three and six steps, and the user must be able to go back or confirm to go to the next.

Examples

  • Loading percentage
  • Progress queue
  • Indeterminate progress
  • File upload
  • Storage meter
  • Password strength
  • Coffee strength
  • Disk usage

The post is still in progress.

Quantity spinbutton

Built with: input type=number · role=spinbutton · aria-valuetext

Do NOT follow the APG pattern. Just follow my solution because it works on all browsers and screen readers..

How it works

The native solution is input type="number" with min, max and step, which already supports Up and Down arrows and swipe. Add role="spinbutton" anyway: WebKit exposes the input as a textbox without it. Add aria-valuetext too, or screen readers announce the value as a percentage. Strip the native spinners with appearance: textfield and the ::-webkit-spin-button pseudo-elements.

It was tested with Chrome 148 and VoiceOver on macOS, with Chrome and VoiceOver on iOS, and with Chrome and NVDA on Windows 11. The post links the bug reports opened for Android and for Apple.

Keyboard

  • Up Arrow: increase by the step.
  • Down Arrow: decrease by the step.
  • Home: set the minimum, if there is one.
  • End: set the maximum, if there is one.
  • On Apple keyboards with no End key: Command or Fn with Left or Right Arrow.

Read the post: Accessible and Functional Quantity Spinbutton Pattern