Skip to content

Accessible HTML and Maintainable CSS

Step 16 of 45

Styles, Interaction States, and Motion

Introduction

This is the third lesson of chapter 4, aligned with course 4 of the Meta Front-End Developer pathway ("HTML and CSS in Depth"). You can now structure forms and lay out pages. This lesson is about making the interface feel alive and stay maintainable: how the browser decides which CSS rule wins, how to style hover, focus and disabled states, how to animate with restraint, and how to organize styles so a team can reuse them.

Many beginner problems come from one root cause: not understanding the cascade. A rule you wrote "does nothing," so you add !important, and soon nobody can change anything. Once you understand specificity and inheritance, most of these problems disappear. The practice scenario is original: a reusable button system for Harbor Workshops with visible keyboard focus and calm, optional motion.

Learning objectives

  1. Explain the cascade: origin, specificity, and source order, and calculate the specificity of selectors.
  2. Use inheritance, inherit, initial, and custom properties (CSS variables).
  3. Write reusable class patterns, with BEM naming and modifier classes.
  4. Style states with :hover, :focus-visible, :active, :disabled, :checked, and :invalid.
  5. Provide visible focus indicators that meet accessibility needs.
  6. Create transitions and keyframe animations, preferring transform and opacity.
  7. Respect prefers-reduced-motion.
  8. Diagnose common layout and cross-browser assumptions.

1. The cascade

When several rules target the same property on the same element, the browser picks one using three criteria, in order:

  1. Origin and importance. Browser defaults < author styles < author !important < user !important. (!important in your stylesheet beats normal rules, which is why it spreads.)
  2. Specificity. More specific selectors win.
  3. Source order. If specificity is equal, the later rule wins.

Calculating specificity

Think of specificity as three counters, compared left to right:

Selector part Counter
Inline style="..." Beats all selectors below (not !important)
ID selector #nav (1, 0, 0)
Class .btn, attribute [type="text"], pseudo-class :hover (0, 1, 0)
Element a, pseudo-element ::before (0, 0, 1)
Universal *, combinators, :where() (0, 0, 0)

Examples:

a              { color: blue; }    /* (0,0,1) */
.link          { color: green; }   /* (0,1,0) */
nav a          { color: red; }     /* (0,0,2) */
nav .link      { color: orange; }  /* (0,1,1) */
#menu .link    { color: purple; }  /* (1,1,0) */

For <nav id="menu"><a class="link">, the purple rule wins because one ID outweighs any number of classes. Counters are not a base-10 number: eleven classes (0,11,0) never beat one ID (1,0,0).

:is() takes the specificity of its most specific argument; :where() always contributes zero, which is useful for low-specificity defaults. :not() takes the specificity of its argument.

Practical rules

  • Style with classes. Class selectors are specific enough to be predictable and low enough to override.
  • Avoid IDs for styling (use them for anchors and label associations).
  • Avoid long descendant chains such as body div.page main ul li a. They are brittle and hard to override.
  • Avoid !important except for utilities intended to always win (for example a .visually-hidden helper).
  • Order your stylesheet from generic to specific: reset, base elements, layout, components, utilities.

2. Inheritance and custom properties

Some properties are inherited from parent to child: color, font-family, font-size, line-height, visibility. Others are not: margin, padding, border, background, width. That is why setting font-family on body styles the whole page but setting border on a div does not border its children.

Keywords control this: inherit (take the parent's value), initial (the CSS default), unset, and revert. A common use: button { font: inherit; } because buttons do not inherit fonts by default.

Custom properties

:root {
  --color-primary: #0b5cad;
  --color-primary-dark: #084480;
  --color-text: #1b2733;
  --radius: 0.5rem;
  --space-2: 0.5rem;
  --space-4: 1rem;
}
.btn {
  background: var(--color-primary);
  border-radius: var(--radius);
  padding: var(--space-2) var(--space-4);
}

Custom properties cascade and inherit, so you can redefine them in a component or in a theme:

@media (prefers-color-scheme: dark) {
  :root { --color-text: #e8eef4; --color-bg: #10171e; }
}
.btn--danger { --color-primary: #a4262c; --color-primary-dark: #7a1c21; }

var(--x, fallback) supplies a default when the variable is undefined. Unlike Sass variables, custom properties exist at runtime, so JavaScript and media queries can change them.

3. Reusable class patterns

A maintainable stylesheet is a small vocabulary of reusable pieces. One widely used convention is BEM (Block, Element, Modifier):

  • Block: a standalone component: .card
  • Element: a part of it: .card__title
  • Modifier: a variant or state: .card--featured
<article class="card card--featured">
  <h3 class="card__title">Pottery for Beginners</h3>
  <a class="btn btn--primary" href="/book/pottery">Book seats</a>
</article>

Each selector is a single class, so specificity stays flat at (0,1,0) and order alone decides conflicts. Benefits: no dependence on HTML structure, easy deletion of unused components, and names that explain intent.

Guidelines:

  • Name by purpose, not appearance: .btn--danger, not .btn--red.
  • Prefer composition (class="btn btn--primary btn--large") over one class per combination.
  • Keep component CSS free of page layout (margins that place the card belong to the layout container).
  • In React projects the same idea appears as components with their own styles, which you will meet in chapter 5.

4. Interaction states

Pseudo-classes

A pseudo-class selects an element in a particular state:

Pseudo-class Matches
:hover Pointer over the element
:focus Element has keyboard focus (any method)
:focus-visible Focus that the browser decides should show an indicator (typically keyboard)
:focus-within A container with a focused descendant
:active Being pressed
:disabled / :enabled Form control state
:checked Selected checkbox or radio
:invalid / :valid Constraint validation result
:required / :optional Presence of required
:first-child, :nth-child(2n), :not() Structural selection
:target The element named by the URL fragment

Order for links and buttons

If you style all of them, remember the order LVHA: :link, :visited, :hover, :active. Because these have equal specificity, later rules win, so :hover must come after :link and :active after :hover.

Focus, the most important state

Keyboard users navigate by focus. If you cannot see where focus is, you cannot use the page. Never write outline: none without a replacement.

.btn:focus-visible {
  outline: 3px solid #ffbf47;
  outline-offset: 2px;
}

:focus-visible shows the indicator for keyboard focus but usually not for mouse clicks on buttons, so designers keep a clean look without harming accessibility. The WCAG 2.2 guidance expects a focus indicator that is clearly visible with sufficient contrast against adjacent colors (see the WCAG "Focus Appearance" and "Non-text Contrast" criteria, and use an indicator at least as thick as a 2 CSS pixel perimeter as a practical target). Verify by pressing Tab through the page.

Hover is not enough

Touch devices have no hover. Never hide essential information or controls behind hover alone, and give focus the same treatment as hover:

.btn:hover,
.btn:focus-visible { background: var(--color-primary-dark); }

@media (hover: hover) {
  .card:hover { box-shadow: 0 6px 16px rgb(0 0 0 / 15%); }
}

Disabled and invalid

.btn:disabled,
.btn[aria-disabled="true"] {
  background: #c9d1d9;
  color: #4b5560;
  cursor: not-allowed;
}
input:user-invalid { border-color: #a4262c; }

:user-invalid only matches after the user has interacted with the field, unlike :invalid, which can flag an empty required field before anyone typed. Check current browser support on MDN before relying on it, and keep :invalid plus a class set by script as a fallback.

State must never be communicated by color alone. Pair the red border with error text (lesson 1) and an icon or a message.

5. Transitions and animation

Transitions

A transition animates a property change between two states:

.btn {
  background: var(--color-primary);
  transform: translateY(0);
  transition: background-color 150ms ease, transform 150ms ease;
}
.btn:hover { transform: translateY(-1px); }

Specify properties explicitly. transition: all animates things you did not intend, and can animate expensive layout properties.

Keyframe animations

@keyframes pulse {
  0%   { transform: scale(1); }
  50%  { transform: scale(1.05); }
  100% { transform: scale(1); }
}
.badge--new {
  animation: pulse 1.2s ease-in-out 3;  /* name, duration, timing, repeat count */
}

Animation properties: animation-name, -duration, -timing-function, -delay, -iteration-count (infinite for endless), -direction, -fill-mode.

Performance: animate cheap properties

Browsers can animate transform and opacity on the compositor without recalculating layout. Animating width, height, top, left, margin or box-shadow forces layout or paint every frame and can stutter on low-end phones. To move something, use transform: translateX(); to fade, use opacity.

Restraint and purpose

Motion should communicate: confirm that a click worked, show where a panel came from, draw attention to an important change. Keep durations short (roughly 100 to 300 ms for interface feedback). Avoid autoplaying, looping, or flashing content. Content that flashes more than three times per second can trigger seizures.

Reduced motion

Some users, particularly those with vestibular disorders, set their operating system to reduce motion. CSS exposes this preference:

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

A more careful approach is to wrap decorative animation in @media (prefers-reduced-motion: no-preference) so that it only runs for users who have not asked for less, and to keep essential state changes (a color change) that do not involve movement. MDN lists prefers-reduced-motion as widely available across browsers.

6. Debugging layout and cross-browser assumptions

Typical sources of "it looks different on another browser":

  • Missing reset. Browsers have different default margins and form styles. Use a small reset (box-sizing, margin: 0 on body, font: inherit on form controls).
  • Assuming a feature exists. Check MDN's browser compatibility table and Baseline status before relying on new CSS. Use @supports for progressive enhancement:
.event-grid { display: flex; flex-wrap: wrap; gap: 1rem; }
@supports (display: grid) {
  .event-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); }
}
  • Margin collapse. Vertical margins of adjacent block elements combine to the larger one, not the sum. Flex and grid items do not collapse.
  • Unexpected stacking. z-index only works on positioned elements (or flex/grid items), and it is scoped to a stacking context. A huge z-index inside a context cannot escape it.
  • Specificity surprises. A rule seems ignored: open DevTools and look for it crossed out (next lesson).
  • Different form control rendering. Style with appearance, but leave native controls alone when unsure; they are accessible by default.

Write standard CSS; avoid vendor prefixes unless a tool adds them for you. Test on at least one Chromium-based browser, Firefox, and Safari (or WebKit), and on a real phone when possible.

Worked example: a reusable button system

Brief. Create buttons for Harbor Workshops: a primary action ("Book seats"), a secondary one ("Details"), and a danger action ("Cancel booking"). They must work on <a> and <button>, show clear hover, focus and disabled states, and animate calmly.

Step 1: tokens. Define colors, radius and spacing as custom properties (see above). Check contrast: white text on #0b5cad has a ratio above 4.5:1, which meets the WCAG AA text requirement.

Step 2: the base class. Reset the differences between <a> and <button>.

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  padding: 0.625rem 1.25rem;
  border: 2px solid transparent;
  border-radius: var(--radius);
  font: inherit;
  font-weight: 600;
  line-height: 1.2;
  text-decoration: none;
  cursor: pointer;
  background: var(--btn-bg, var(--color-primary));
  color: var(--btn-fg, #fff);
  transition: background-color 150ms ease, transform 150ms ease;
}

font: inherit aligns <button> text with <a> text. --btn-bg and --btn-fg are hooks that modifiers override with a fallback to the primary colors.

Step 3: modifiers. Only change the hooks.

.btn--secondary { --btn-bg: transparent; --btn-fg: var(--color-primary); border-color: currentColor; }
.btn--danger    { --btn-bg: #a4262c; }

Step 4: states.

.btn:hover { filter: brightness(0.92); }
.btn:active { transform: translateY(1px); }
.btn:focus-visible { outline: 3px solid #ffbf47; outline-offset: 2px; }
.btn:disabled, .btn[aria-disabled="true"] {
  opacity: 0.55;
  cursor: not-allowed;
  filter: none;
}

Step 5: reduced motion.

@media (prefers-reduced-motion: reduce) {
  .btn { transition: none; }
  .btn:active { transform: none; }
}

Step 6: use.

<a class="btn" href="/book/pottery">Book seats</a>
<a class="btn btn--secondary" href="/workshops/pottery">Details</a>
<button class="btn btn--danger" type="button">Cancel booking</button>
<button class="btn" type="submit" disabled>Book seats</button>

Why this design? Single-class selectors keep specificity flat, so a modifier or a page-level override with one class and later source order wins without !important. Custom properties make variants small. The focus ring uses :focus-visible with a high-contrast color, and the reduced-motion block removes movement. Alternatives are worse: an ID-based rule such as #book would be impossible to override with classes; outline: none would hide keyboard focus; transition: all would animate unrelated properties; and a div with a click handler would lose keyboard support and button semantics.

Common pitfalls

  • Fighting specificity with !important or deeper selectors, instead of lowering specificity.
  • Using IDs and long selector chains that cannot be overridden cleanly.
  • Removing the focus outline with no replacement.
  • Hover-only interactions that are impossible on touch devices.
  • Putting :hover before :link/:visited so hover styling never applies.
  • Animating width, top, or margin and causing jank; use transform and opacity.
  • transition: all hiding accidental animation.
  • Ignoring reduced-motion settings.
  • Naming classes by appearance (.red-text) so a redesign makes names lie.
  • Expecting margin or border to inherit.
  • Relying on color alone to show an error or selected state.

Exam tip: For "which rule wins?" questions, compare in order: !important, inline, IDs, classes/attributes/pseudo-classes, elements, then source order. Count each category separately; never add them across columns. For accessibility questions, the correct answer almost always keeps a visible focus indicator, supports keyboard use, and honors prefers-reduced-motion.

Mini case study: the button that will not change

Situation. A teammate wants the "Cancel booking" button to be dark red, but it stays blue. The CSS is:

.btn { background: #0b5cad; }                 /* in components.css */
.btn--danger { background: #a4262c; }         /* in components.css, but loaded earlier */
.booking-panel button { background: #0b5cad; }/* in layout.css */

HTML: <div class="booking-panel"><button class="btn btn--danger">Cancel booking</button></div>

The teammate suggests adding !important to .btn--danger.

Questions. (1) Why is the button blue? (2) Is !important the right fix? (3) What is the fix?

Solution.

  1. Specificity: .booking-panel button is (0,1,1) (one class, one element). .btn--danger is (0,1,0). The layout rule is more specific and wins regardless of source order.
  2. !important would work but is a trap: the next change will need another !important, and the real problem (a layout file styling a component's color) stays.
  3. Remove the color rule from the layout file, since layout CSS should position, not theme, components. If the rule must stay, make it equal and order it correctly: .booking-panel .btn { } is (0,2,0) and would beat .btn--danger again, so it should not set background at all. After removing it, .btn--danger (0,1,0) and .btn (0,1,0) have equal specificity and source order decides, so ensure the modifier comes after the base class in the file.

Verify in DevTools: select the button, find the crossed-out background declarations in the Styles pane, and confirm that .btn--danger is the one applied.

Key terms

  • Cascade: the algorithm that resolves conflicting declarations.
  • Specificity: a weight computed from a selector's parts.
  • Inheritance: passing some property values from parent to child.
  • Custom property: a --name variable read with var().
  • BEM: Block, Element, Modifier naming convention.
  • Pseudo-class: a selector keyword for an element's state.
  • :focus-visible: focus styling shown when the browser judges it helpful, typically for keyboard.
  • Transition: an animation between two states of a property.
  • Keyframes: the named steps of an animation.
  • Compositor: the browser stage that can animate transform and opacity cheaply.
  • prefers-reduced-motion: a media feature reporting the user's motion preference.

Summary checklist

  • I can compute specificity and predict which of two rules wins.
  • I style with flat, single-class selectors and avoid IDs and !important.
  • I know which properties inherit and use inherit and custom properties deliberately.
  • I name components with a consistent convention such as BEM and compose modifiers.
  • I style hover, :focus-visible, active, and disabled states for every interactive component.
  • I never remove focus outlines without an equally visible replacement.
  • I do not hide essential content behind hover.
  • I animate transform and opacity, keep durations short, and avoid transition: all.
  • I support prefers-reduced-motion.
  • I use @supports and compatibility tables for newer features.
  • I debug "ignored" rules by looking for specificity and order problems first.

Official sources