Design System
The tokens, components and accessible patterns that I used to build this site and in my career.
Table of contents (inline)
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 monolabel,metaandlink-monostyles 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
SkipLinkfirst on every page, and send focus to the pageh1after 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
| Token | Value | Use it for |
|---|---|---|
white | #ffffff | Text 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 | #ffffff | The page background. |
fill | #f3f3f5 | muted. 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 | #edebf4 | Identifies 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 | #d7d7dc | Dividers. 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-control | ink | Border of a form control. Aliases ink, so a control edge reaches 3:1 (non-text contrast) against its background. |
ink | #16161a | Text, borders and hard shadows. 18.04:1 on paper. Text on marker is always ink. |
ink-soft | #4b4b55 | Secondary 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 | #66666f | Tertiary text on paper (5.68:1), fill (5.13:1) and wash (4.81:1). |
lilac | #5e548e | Fill 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 | #4a4170 | Hover 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 | #3d3459 | Small 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 | #82002a | Errors 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 | #ffe600 | The highlighter band behind Underline. Only ever behind ink text (14.24:1). It should never be a text colour. |
focus-ring-primary | #0066ff | Outer 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 | #a5c8ff | Inner 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. |
| Token | Value | Use it for |
|---|---|---|
background | paper | Body copy is foreground. |
foreground | ink | Body copy and headings on background, card, muted and secondary. |
card | white | Surface of Slab and cards. Text on it is card-foreground. |
card-foreground | ink | Text on card. |
popover | white | Surface of menus, listboxes and dialogs. Text on it is popover-foreground. |
popover-foreground | ink | Text on popover. |
primary | ink | Strongest filled control: the solid ink button. Text on it is primary-foreground. |
primary-foreground | white | Text and icons on primary (18.04:1). |
secondary | wash | Current nav item, hover rows, table headers, mark. Text on it is secondary-foreground. |
secondary-foreground | ink | Text on secondary (15.29:1). |
muted | fill | Text on it is foreground or muted-foreground. |
muted-foreground | ink-soft | Secondary text on background, card and muted: descriptions, captions, footer copy (7.78:1 or better). |
accent | lilac | The 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-text | lilac-deep | The 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-foreground | white | Text and icons on accent (6.70:1). |
destructive | danger | Error text and destructive fills. Text on it is destructive-foreground. |
destructive-foreground | white | Text on destructive (10.59:1). |
border | ink | Every visible edge: slab, button, header rule. 18.04:1 on background (3:1 required). |
input | line-control | Border of inputs, selects and textareas. |
ring | ink | Default ring color. The site draws its focus indicator from focus-ring-primary, not from this. |
Typography
| Token | Value |
|---|---|
font-sans | ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji" |
font-mono | ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace |
font-serif | Georgia, "Iowan Old Style", "Times New Roman", serif |
| Style | Value | Sample | Use it for |
|---|---|---|---|
post-h1 | 3.9rem / 1.02, 700, -0.035em | BLOG | Page 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-h2 | 2.4rem / 1.12, 700, -0.025em | Why naming matters | Section heading, sentence case, with a 3px ink rule under it. Clamps from 1.7rem: clamp(1.7rem, 3.2vw, 2.4rem). |
post-h3 | 1.7rem / 1.25, 700, -0.015em | Naming principles | Subsection, with a 5px accent rail on the inline start. Clamps from 1.4rem: clamp(1.4rem, 2.2vw, 1.7rem). |
post-h4 | 1.15rem / 1.35, 700, -0.01em | Check a name | Third level, with a 3px accent rail. |
post-h5 | 1rem / 1.4, 700, -0.005em | Spelled for each tool | Fourth level, with a 2px accent rail. |
post-h6 | 0.9rem / 1.45, 700 | Notes | Fifth level, with a 1px accent rail. Colour is foreground at 88%. |
| Style | Value | Sample | Use it for |
|---|---|---|---|
body | 1rem / 1.5, 400 | Insights, tutorials, and best practices for building accessible web experiences. | Body copy and UI text on background, card and muted in foreground. |
lead | 1.25rem / 1.4, 400 | Learn from real-world examples. | Page intro under an h1 (text-xl from sm, text-lg below). In muted-foreground. |
small | 0.875rem / 1.25rem, 400 | Frontend developer and accessibility engineer. | Footer copy, captions and helper text in muted-foreground. |
card-title | 2.25rem / 1.25, 700, -0.02em | Everything Is A List | Post title in a list from md. Underlined 3px foreground on hover. 1.5rem below md. |
card-title-sm | 1.5rem / 1.25, 700, -0.02em | Everything Is A List | Post title in a list below md. |
quote | 1rem / 1.75, 400, 0.005em | The first thing a screen reader announces is the name. | Blockquote: serif italic with a 2px accent rule on the inline start. |
| Style | Value | Sample | Use it for |
|---|---|---|---|
label | 0.8125rem / 1.5, 700, 0.08em | PUBLISHED POSTS | Label. Set in capitals, in accent-text. Eyebrows, listbox headings, dates. |
meta | 0.7rem / 1.5, 700, 0.14em | SEP 23, 2026 · 7 MIN READ | Date and reading time above a post title. Capitals, accent. |
link-mono | 0.9rem / 1.5, 700, 0.14em | READ MORE → | The "read more" link in a post entry. Capitals, a link-brut underline. |
code | 0.875em / 1.5, 600 | aria-describedby | Inline code: 0.875em of its parent, in a accent 12% tint with a 2px edge and radius-code. |
pre | 1rem / 1.6, 500 | const 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
| Token | Value | Length | Use it for |
|---|---|---|---|
space-1 | 0.25rem | Icons with text | |
space-2 | 0.5rem | Gap between chips and inside tight rows (gap-2). | |
space-3 | 0.75rem | Gap in meta rows (gap-3); padding of a compact slab (p-3). | |
space-4 | 1rem | Page gutter on a phone (px-4); gap in a post entry (gap-4); button inline padding. | |
space-6 | 1.5rem | Page gutter from the sm breakpoint (px-6); padding of a slab and a dialog (p-6); inset of the back-to-top button. | |
space-8 | 2rem | Gap between footer columns (gap-8); roomy panel padding (p-8). | |
space-10 | 2.5rem | Vertical rhythm of a post entry (py-10); padding of a large slab (p-10). | |
space-12 | 3rem | Vertical rhythm of a post entry from md (md:py-12). |
Borders and corners
| Token | Value | Sample | Use it for |
|---|---|---|---|
radius | 8px | The only corner: every Tailwind radius step collapses to it, rounded-full included. Buttons, slabs, chips, dialogs, inputs. | |
radius-code | 4px | Inline code and kbd-sized marks, the one place the corner is tighter. |
| Token | Value | Sample | Use it for |
|---|---|---|---|
bw | 2px | Default line: inputs, code, tables, the table-of-contents rule. | |
bw-thick | 3px | Edge of a slab, a button and the header rule: the signature heavy line. | |
focus-ring-width | 4px | Width of the outer focus ring where it is drawn as an outline. |
Shadows
| Token | Sample | Use it for |
|---|---|---|
shadow | box-shadow: 4px 4px 0 0 #16161a; | Slab and the table of contents. |
shadow-sm | box-shadow: 3px 3px 0 0 #16161a; | Button |
shadow-pressed | box-shadow: 1px 1px 0 0 #16161a; | Hover and press shadow of Button |
focus-halo | box-shadow: 0 0 0 3px #a5c8ff, 0 0 0 7px #0066ff; | 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
| Token | Value | Use it for |
|---|---|---|
duration-press | 80ms | Button on hover and press. |
duration-link | 120ms | Link on hover. |
duration-fade | 150ms | Chip on hover, heading-anchor fade, tooltip fade. |
duration-menu | 180ms | Mobile menu and listbox entering. |
duration-dialog | 300ms | Dialog and drawer fade and slide, with @starting-style. Under prefers-reduced-motion every duration is 1ms and every translate is none. |
Layers
| Token | Value | Use it for |
|---|---|---|
z-raised | 10 | A focused link lifts so its ring is not clipped. |
z-fixed | 40 | Sticky table of contents and the back-to-top button. |
z-header | 50 | Site header, and the language listbox that opens beneath it. |
z-skip-link | 100 | The skip link when focused: above everything. |
Breakpoints
| Token | Value | Use it for |
|---|---|---|
breakpoint-sm | 40rem | Tailwind sm: gutters go from 1rem to 1.5rem; footer columns appear. |
breakpoint-md | 48rem | Tailwind md: post titles grow to 2.25rem; dialogs may become side drawers. |
breakpoint-lg | 64rem | Tailwind lg: the header shows its full navigation instead of the menu button. |
breakpoint-2xl | 1400px | The 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(accentfill,accent-foregroundtext) for the one action a view exists for,quiet(card) for the rest, anddanger(destructive) only for an action that cannot be undone. Say what it does: "Erase account". - Verb first, sentence case. Never two
primarybuttons in one view. - If the button is only a visual icon you can use
sr-onlyto hide the accessible name of the button or anaria-label, then it should go along with a tooltip explaining the purpose for sighted users. The icon should have asize="icon"(a 56px square). - Targets are at least 44px (
min-block-size: 2.75rem). Focus is thefocus-haloshadow. Underprefers-reduced-motionthe 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 3pxforegroundunderline at a 4px offset.nav: a 3pxaccentunderline. The current page setscurrentand becomes a filledsecondarypill with no underline, and getsaria-current="page".muted: footer-size text inmuted-foregroundwith a 2pxaccentunderline that appears on hover and focus. Hover darkens the text toforeground.- 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 toz-raisedso 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, ornone. - Give it a landmark or heading when it holds content (
<article>with anaria-labelledby). - Do not nest a slab inside a slab. Use
border1px or aRuleinside.
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-textonbackground,mutedandsecondaryis 9.70:1 or better (AAA), so it stays readable at 13px.accentalone is 5.68:1 there, which is too thin for small capitals. Do not put the label onaccentor 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 onmarker).
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 3pxborderunderlinepost-h3topost-h6: sentence case with anaccentrail 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
blockquoteand acitefor 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
CodeBlockfor 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
Kbdper key:ShiftthenTab, 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:
paperor#FFF9F5, 17.29:1 or better. - Comments:
#FFFFFFin 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
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-texton a 10%accenttint, with its 1px edge in the same colour. Hover deepens the tint to 20%. The text isaccent-text, notaccentorlilac-dark: on the 20% hover tint overwash,accentfalls to 4.31:1 andlilac-darkto 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 overpaper. - A link that opens a new page (
target="_blank") draws theexternal-linkicon after its text, 16px andaria-hidden, and announces "opens in a new tab" through a hidden text. Give itrel="noopener noreferrer". A link that stays in the tab has no icon. - Tag (no
href):foregroundtext on themutedfill with amuted-foregroundedge and no hover.
Chip List
Content
A wrapping list of Chips with a space-2 gap, built as a real ul so a screen reader announces the count.
- Pass
label("Topics") so the list has a name. Passitemswithexternal: truefor links that open a new page. An item without anhrefis a plain tag, and a list may mix both. - Use it for a post's topics or a talk's tags.
Post Item
Content
- Read more
7 minutes read
Learn how to build a semantic, accessible and interactive Card UI using HTML and CSS
Do you know how to build semantics and accessibles Interactive Cards using HTML and CSS? Do you know that Interactive Card can be simple and complex? Do you know what a stretched technique is? If the answer is no, read this post and learn how to build compliant Interactive Cards for your product!
- Read more
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.
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
metainaccent: the date, a square dot, the reading time. Pass the date already formatted for the locale.dateTimeis 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 frommd), underlined 3px on hover. The description ismuted-foreground. - The "Read more" link is
link-brutin mono capitals. Give it the post's title in anaria-labelso repeated links have distinct names. - Entries are separated by a 2px
borderline, withspace-10(space-12frommd) 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
ulwith anaria-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 aulnamed for what it holds ("Products in this image"). - The marker is a
buttonnamed bytriggerLabel, withpopovertargetpointing at the card andpopovertargetaction="show". - The card is an
awithpopover="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 aulnamed for what it holds. Each marker is abuttonnamed bytriggerLabel, withpopovertargetpointing at the card. - The card is a
dialogwithpopover="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 ispopovertargetwithpopovertargetaction="hide", so it needs no script. - Inside it is a
sectionthat holds aheader(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-motionstops the pulse. With six or more markers, add a "View all" link below the image.
TextField
Forms
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 withrequired; the asterisk isdestructiveand hidden from assistive technology because therequiredattribute is already set on the input. hintanderrorare linked througharia-describedby. Explain the fix in the error: "Enter an email address, such as name@example.com.", not "Invalid".- An invalid field turns its edge
destructiveand setsaria-invalid. The error text should be always visible until the user addresses it. - Minimum height 44px. The edge is
input, at least 3:1 againstbackground.
Related components: Form Fields
Dialog
Surfaces
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 thearia-labelledbyattribute and receives focus on open. Focus returns to the control that opened it on close.showModal()method: native method of thedialogtag to open it. It makes the page behind inert, and a backdrop.variant="drawer"slides in frompositionleftorright.- 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.
asideis a stickySlab(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(mainhasid="main-content"andtabindex="-1"). - It appears only on focus, at
z-skip-link, with thefocus-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 itslistboxandaria-expanded, and is at least 44px. - Options are
role="option"witharia-selected. The selected one has anaccentbar on its inline start, asecondaryground and a check. - Each option shows the name in its own language. Set
langon 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.
wideshows thenavlinks andcompact, below thelgbreakpoint, swaps them for an icon-only menu button, named "Open menu", that opens a modal dialog under the header.- Place
SkipLinkbefore 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"andaria-expanded. - The menu is a modal
dialognamed "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 andaria-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
headerelement.SkipLinkis its first child, anato#main-content, so the keyboard reaches it first. - The name is an
ato the home page. It is a link, not a heading or an image. - The wide navigation is a
navnamed "Main navigation" that holdsalinks. The current page hasaria-current="page". Belowlgit isdisplay: none, so it leaves the accessibility tree instead of staying as a hidden duplicate of the menu. - The language switcher is a
buttonwitharia-haspopup="listbox"andaria-expanded. Its options arebuttons withrole="option"andaria-selected. - The compact menu button is a
buttonwitharia-haspopup="dialog"andaria-expanded. The menu is a nativedialognamed by anh2, hidden from sight, througharia-labelledby: "Mobile navigation". - Inside the dialog are a
navand anolwithrole="list"ofalinks, each named "Go to Blog". The numbers arearia-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
smallinmuted-foreground; links aremuted, with theaccentunderline 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 isspace-12above andspace-8below. - 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
footerelement and it is placed inmediately after themainelement. - Every column has its own heading like an
h3for the name and anh4for "Navigation" and "Get in touch". The webring has anh2. - The email is an
awithmailto:. The social links are icon-onlyawithtarget="_blank"andrel="noopener noreferrer", named witharia-label("Go to my Github's profile"), and the icon isaria-hidden. The webring links userel="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 hiddenspanwithrole="status", so it is announced without moving focus. - The icon is
aria-hidden, and the closing note is ap.
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), oraccentfor 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.Spacescrolls the page instead. - A button is activated with
EnterorSpace.
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>witharia-expanded. - Jump to a section of the same page: an
<a>withhref="#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>witharia-haspopupandaria-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: nonewithout a replacement focus style. Keyboard and screen reader users need a visible focus on every interactive element.
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.SpaceorEnter: 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
::markeror 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-pressedon 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
selecttag. - 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"andaria-pressedtoggles 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
requiredmeans at least one is checked, and the error is described once and shared througharia-describedby. - Switch:
role="switch"on a real checkbox, so its checked state is the state.
Validation
aria-invalidis always present, “true” or “false”, never omitted when valid.aria-describedbypoints 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 ona 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-describedbyandrole="status". - Validation: the placeholder is a disabled empty option. An empty submit sets
aria-invalid, ties the message to the control witharia-describedbyand 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-labelthat 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-labelthat identifies it. - Give each item
aria-setsize(the total number of items) andaria-posinset(its position). ARIA allows them on the listitem, not on the link itself. - Give each item an
aria-labelthat 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