How to buildOctober 11, 202611 min read
UX/UI design: from research to a design systemStep 5 of 9

Anatomy of a UI component: button, input, card and their states

In short

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.

FundamentalsLevel: BeginnerUI Components

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
  1. 01A component is a contract between design and code
  2. 02Anatomy: container, label, icon, helper text
  3. 03Variants and sizes: primary, secondary, ghost; S/M/L
  4. 04States: hover, focus-visible, active, disabled, loading, error
  5. 05Input: label, placeholder, hint, error
  6. 06Card: hit area and nested actions
  7. 07A 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
The same parts across three basic components

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
Button and input states: what changes and what to watch

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.

index.html
<!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>
Markup and the submit script; a 1.5-second pause stands in for a real request
button.css
: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; }
}
Button variants and states in plain CSS

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.

  1. Hover and active

    Hover over “Book now” — the background darkens; hold the mouse button — it darkens further and the button shifts down slightly.

  2. Focus

    Click “Call us” — no outline. Press Tab — the outline appears on “Cancel”.

  3. Disabled

    Keep pressing Tab: “No slots available” is skipped, and clicking it does nothing.

  4. 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.

  5. 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.

  1. 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.

  2. Filled

    An entered value looks different from a placeholder: dark text, not grey.

  3. 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.

  4. 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.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.