A UI component is a reusable interface element with predefined parts, variants and states: a button, input or card is specified once so it can be built without guesswork. For each component you define its anatomy (what it is made of), its variants (primary, secondary, sizes) and every state — default, hover, focus, active, disabled, loading and error. A state missing from the mockup still ends up in the product; the only difference is that whoever writes the code decides how it looks.
After this you will be able to
- Break a button, an input and a card into parts: container, label, icon, helper text, actions
- List every state of a component — default, hover, focus-visible, active, disabled, loading, error — and describe what changes in each
- Build a variant × state matrix and a component spec for developer handoff
- Explain why focus must never be hidden and why a placeholder does not replace a field label
Verified with
HTML + CSSMicrosoft Edge 155Playwright 1.64
Checked against docs: October 11, 2026
Contents
- A component is a contract between design and code
- Anatomy: container, label, icon, helper text
- Variants and sizes: primary, secondary, ghost; S/M/L
- States: hover, focus-visible, active, disabled, loading, error
- Input: label, placeholder, hint, error
- Card: hit area and nested actions
- A component spec for developer handoff
A component is a contract between design and code
A “Book now” button appears in a dozen places: the header, a service card, a form, a modal. If it is redrawn each time, a couple of months later the product has five slightly different blue buttons with different padding, radii and shades. Components solve this: the element is described once — parts, variants, states, usage rules — and then simply reused.
For a designer, a component is a main element in the file: in Figma it is literally called a component, and its copies on screens are instances. For a developer, it is code with parameters, such as <Button variant="primary" size="m">. The contract works when both sides use the same names: the same variants, the same states, the same property names. A set of components is a UI kit; a UI kit plus tokens, rules and documentation is a design system.
Anatomy: container, label, icon, helper text
Anatomy means breaking a component into named parts. The names are not jargon for its own sake: when the spec says “in the error state only the container border changes”, the developer knows exactly which property to touch. Apple’s Human Interface Guidelines describe a button through three attributes: style (size, color, shape), content (icon, text or both) and role (the meaning of the action: primary, cancel, destructive).
Container
- Button
- Background, border, radius, padding
- Input
- Border or fill, height, radius
- Card
- Background, border or shadow, padding, hit area
Label
- Button
- An action verb: “Book now”, “Save”
- Input
- A label above the field, always visible
- Card
- The title, which is the card’s main link
Icon
- Button
- Optional, before or after the text
- Input
- Optional: search, show password, clear
- Card
- An image or icon at the top
Helper text
- Button
- None: explanations go next to the button
- Input
- A hint and an error message below the field
- Card
- Description and meta: price, date, tags
Actions
- Button
- The button is the action
- Input
- Sometimes a button inside the field (“Clear”)
- Card
- Main action is navigation; secondary actions are separate buttons
Hit area is part of the anatomy too. Apple recommends a hit region of at least 44×44 pt for a button. WCAG 2.2 success criterion 2.5.8 (level AA) requires pointer targets to be at least 24×24 CSS pixels, with exceptions listed in the standard. The visible shape can be smaller; the clickable area cannot.
Variants and sizes: primary, secondary, ghost; S/M/L
A variant is a predefined version of a component for actions of different importance. A button usually has three or four levels of prominence:
Primary
- Looks like
- Filled with the brand color
- Use for
- The main action on the screen: “Book now”, “Pay”
Secondary
- Looks like
- Outline or light fill
- Use for
- An alternative next to the main action: “Call us”, “Learn more”
Ghost (text)
- Looks like
- Text only; a fill appears on hover
- Use for
- Tertiary actions: “Cancel”, “Skip”
Destructive
- Looks like
- Red accent
- Use for
- Deleting and other irreversible actions
Apple recommends keeping prominent buttons to one or two per view: more accents mean more time spent choosing. The same guide adds a second rule: distinguish the preferred option by style, not size — buttons of different sizes side by side look inconsistent.
Sizes (S, M, L) change height, padding and font size, not meaning. Take them from the same spacing scale as the rest of the interface (the scale is covered in the visual hierarchy lesson). For the main action on a phone, pick a size whose hit area meets the platform recommendation.
States: hover, focus-visible, active, disabled, loading, error
A state is how a component looks and behaves at a given moment. For the user it answers the question “did it register?”. Nielsen Norman Group advises keeping feedback fast: focus and pressed states should appear within roughly 100–150 ms, otherwise people press again.
Default
- When
- The component is available
- What changes
- The base look
- Watch out
- The label contrasts with the background
Hover
- When
- The pointer is over it
- What changes
- Background darkens or a fill appears
- Watch out
- Touch screens have no hover — never hide meaning behind it
Focus-visible
- When
- Keyboard focus
- What changes
- An outline around the component
- Watch out
- WCAG 2.4.7: focus must be visible; the outline needs at least 3:1 against adjacent colors
Active
- When
- The moment of pressing
- What changes
- Even darker fill, a slight shift
- Watch out
- Apple: a custom button must have a press state
Disabled
- When
- The action is unavailable right now
- What changes
- Muted background and label
- Watch out
- Not focusable; explain why next to it
Loading
- When
- The action is in progress
- What changes
- A spinner; the label stays
- Watch out
- Repeat presses are ignored; the result is announced
Error
- When
- Invalid value or a failed action
- What changes
- Border and an error message
- Watch out
- Color must not be the only cue
On focus. The :focus pseudo-class matches any focused element, while :focus-visible matches only when the browser decides focus should be shown: when tabbing with the keyboard — yes; when clicking a button with a mouse — usually not. This lets you give keyboard users a strong outline without distracting mouse users. Never remove the indicator entirely: MDN warns that doing so makes keyboard navigation inaccessible for sighted users.
On disabled. The browser handles the disabled attribute on a <button> itself: the button cannot be pressed and drops out of the Tab order. WCAG 1.4.11 exempts inactive components from the 3:1 contrast requirement, but the label still has to be readable. If an unavailable button must stay focusable, use aria-disabled="true" — then you block activation and style the state yourself.
Here is a small example: three button variants, a disabled button and a loading state. Save it as two files side by side and open it in a browser.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Button states</title>
<link rel="stylesheet" href="button.css">
</head>
<body>
<form id="booking">
<button class="btn btn--primary" type="submit">Book now</button>
<button class="btn btn--secondary" type="button">Call us</button>
<button class="btn btn--ghost" type="button">Cancel</button>
<button class="btn btn--primary" type="button" disabled>No slots available</button>
<p id="status" role="status"></p>
</form>
<script>
const form = document.getElementById("booking");
const submit = form.querySelector('[type="submit"]');
const status = document.getElementById("status");
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (submit.getAttribute("aria-busy") === "true") return; // already sending
submit.setAttribute("aria-busy", "true");
status.textContent = "Sending your request…";
await new Promise((resolve) => setTimeout(resolve, 1500)); // stands in for a request
submit.removeAttribute("aria-busy");
status.textContent = "Request sent";
});
</script>
</body>
</html>:root {
--brand: #1d4ed8;
--brand-hover: #1e40af;
--brand-active: #1e3a8a;
--brand-tint: #eff6ff;
--on-brand: #ffffff;
--muted-bg: #e5e7eb;
--muted-text: #4b5563;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 44px;
padding: 0 20px;
border: 1px solid transparent;
border-radius: 10px;
font: 600 16px/1.2 system-ui, sans-serif;
cursor: pointer;
transition: background-color 150ms ease-out, transform 100ms ease-out;
}
/* Variants: one component, different prominence */
.btn--primary { background: var(--brand); color: var(--on-brand); }
.btn--secondary { background: transparent; color: var(--brand); border-color: currentColor; }
.btn--ghost { background: transparent; color: var(--brand); }
/* Hover and active only for an available button */
.btn--primary:not(:disabled, [aria-busy="true"]):hover { background: var(--brand-hover); }
.btn--secondary:not(:disabled, [aria-busy="true"]):hover,
.btn--ghost:not(:disabled, [aria-busy="true"]):hover { background: var(--brand-tint); }
.btn--primary:not(:disabled, [aria-busy="true"]):active { background: var(--brand-active); }
.btn:not(:disabled, [aria-busy="true"]):active { transform: translateY(1px); }
/* Keyboard focus: outline at least 3:1 against the page */
.btn:focus-visible {
outline: 3px solid var(--brand);
outline-offset: 2px;
}
/* Disabled: muted, but the label stays readable */
.btn:disabled {
background: var(--muted-bg);
color: var(--muted-text);
border-color: transparent;
cursor: not-allowed;
}
/* Loading: the button keeps focus, the script ignores repeat presses */
.btn[aria-busy="true"] { cursor: progress; }
.btn[aria-busy="true"]::before {
content: "";
width: 16px;
height: 16px;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: btn-spin 700ms linear infinite;
}
@keyframes btn-spin {
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.btn { transition: none; }
.btn[aria-busy="true"]::before { animation-duration: 2s; }
}The aria-busy="true" attribute tells assistive technologies that an element’s content is being updated; here it also serves as the styling hook for the spinner. It announces nothing by itself: the result is read out by the role="status" region, whose text screen readers announce without moving focus.
Hover and active
Hover over “Book now” — the background darkens; hold the mouse button — it darkens further and the button shifts down slightly.
Focus
Click “Call us” — no outline. Press Tab — the outline appears on “Cancel”.
Disabled
Keep pressing Tab: “No slots available” is skipped, and clicking it does nothing.
Loading
Press Enter on “Book now” several times in a row: a spinner appears, only one submission happens, focus stays on the button and a status line appears.
Reduced motion
Turn on “reduce motion” in your OS — transitions switch off and the spinner slows down.
We ran these steps automatically in Microsoft Edge 155 with Playwright: every state switches correctly, label-to-background contrast stays at 4.5:1 or higher in every state including disabled, and the focus outline against the white page is 6.7:1.
Input: label, placeholder, hint, error
An input consists of a label, the field itself, a hint below it and a slot for an error message. The most common mockup mistake is dropping the label and leaving only grey text inside the field — the placeholder. Nielsen Norman Group explains why that hurts:
- the text disappears as soon as typing starts, so people have to remember what the field was for
- a filled-in form is hard to review before submitting because the labels are gone
- grey text has poor contrast, and some screen readers don’t announce it
- people mistake the hint for a value that is already filled in and skip the field
Nielsen Norman Group’s conclusion: put the label and any essential hint outside the field. A placeholder is fine as a format example (“+1 555 000 0000”), but not instead of a label.
Default and focus
The label sits above the field, and the border contrasts with the background. On focus the border thickens or changes color, and the caret blinks inside.
Filled
An entered value looks different from a placeholder: dark text, not grey.
Error
Shown after a submit attempt or after leaving the field, not on page load. The border turns red and a message below says what is wrong and how to fix it. MDN recommends setting
aria-invalid="true"only as a result of validation and linking the field to the error text.Disabled and read-only
An unavailable field is muted. Read-only is a different state: the value can still be selected and copied.
Card: hit area and nested actions
Nielsen Norman Group defines a card as a container for a few short, related pieces of information — a brief representation of an item that links to more detail. Typical parts: an image, a title, a short description, meta (date, price, tags) and sometimes a secondary action.
Most often the whole card leads to the item page: a large hit area is easier to use on a phone and with a mouse. But cards also contain their own buttons — “Save”, “Share”. You can’t wrap the whole card in an <a> and put a button inside: HTML forbids interactive elements inside a link. The working technique is to make the title the link and stretch its hit area over the card with CSS; secondary buttons sit above that layer.
What breaks
- The whole card is a link with a “Save” button inside: invalid markup, and clicking the heart opens the page
- Only a tiny “Learn more” label is clickable
- Keyboard focus is visible only on the text, not the card
- Hover is the only hint that the card is clickable
What works
- The title is the link, and its hit area covers the card
- Secondary actions are separate buttons, visually set apart from the content
- On focus-visible the outline wraps the whole card
- Shadow, border and a linked title signal clickability, not hover alone
A card has the same states as a button plus a few of its own: hover (a shadow or lift), focus-visible, selected — when cards are chosen, like pricing plans — and loading, when a skeleton temporarily takes its place. Nielsen Norman Group also points out a limit: cards work well for browsing mixed content but poorly when options must be compared — a list or table is better there.
A component spec for developer handoff
A spec is a short document or a page in the design file that holds everything needed to build the component. In our experience it saves the most time in review: questions like “what does this look like on error?” get answered before the code, not after.
- Purpose: what problem the component solves and when not to use it
- Anatomy: a diagram with labelled parts, marked as required or optional
- Variants and sizes: a list with usage examples
- Variant × state matrix: every cell is drawn or explicitly marked “not applicable”
- Dimensions and spacing: height, padding, radius, minimum hit area — as tokens, not by eye
- Behavior: what happens on press, during a long load, on error; where focus goes after the action
- Copy: label rules, maximum length, wrapping of long text
- Accessibility: contrast, visible focus, an accessible name for icon-only buttons
How this was verified3 · passedchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.
Sources
- Nielsen Norman Group — Button States: Communicate Interaction
- Apple Human Interface Guidelines — Buttons
- W3C WAI — Understanding SC 2.4.7: Focus Visible
- W3C WAI — Understanding SC 1.4.11: Non-text Contrast
- Nielsen Norman Group — Placeholders in Form Fields Are Harmful
- Nielsen Norman Group — Cards: UI-Component Definition