CSS: Accessibility & Modern Features
Write inclusive CSS and use the latest features that make layouts more powerful and flexible.
Semantic CSS (Meaningful Class Names)
Semantic CSS means naming classes by their purpose, not their visual appearance. A class named .btn-danger is semantic, it describes what the button means. A class named .red-button is not, it describes how it looks, and becomes misleading if the colour ever changes.
Semantic class names
Semantic names make CSS maintainable. If the design changes, you update the CSS rule, not every HTML element that uses the class.
- .btn-primary, .btn-danger, describes role; works regardless of colour
- .card-header, .card-body, describes structure, not appearance
- .is-active, .is-hidden, state classes with a prefix clarify intent
- Avoid: .red-text, .mt-20, .big-font, these describe appearance, not meaning
- BEM (Block__Element--Modifier) is a popular convention: .card__title--featured
Semantic class names
The second button uses names that describe its role, not its colour or size.
<!-- Avoid: appearance-based names -->
<button class="red-button big-text">Delete</button>
<!-- Prefer: semantic names -->
<button class="btn btn-danger">Delete</button>Focus States and Keyboard Navigation
Keyboard users navigate pages by pressing Tab to move between interactive elements. The focused element must always be visually distinct, this is the focus ring. Removing it without a replacement is one of the most common accessibility failures.
Focus states
Never use outline: none or outline: 0 without providing a visible custom focus indicator. WCAG 2.1 requires focus indicators to be visible and meet a minimum contrast ratio.
- The default browser focus ring is the minimum, but you can replace it with something better-looking
- outline: none, removes the focus ring entirely, only do this if you provide a custom one
- box-shadow: 0 0 0 3px ..., a common custom focus ring that respects border-radius
- Test your page using only the keyboard: Tab, Shift+Tab, Enter, Space, arrow keys
- All interactive elements (links, buttons, inputs) must be reachable and operable by keyboard
Custom focus ring
Tab to each element, a visible custom focus indicator replaces the default outline.
.btn, .link {
display: inline-block;
padding: 10px 20px;
background: steelblue;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
text-decoration: none;
margin-right: 10px;
/* remove default, provide custom */
outline: none;
}
.btn:focus-visible,
.link:focus-visible {
box-shadow: 0 0 0 3px white, 0 0 0 5px steelblue;
}Screen Reader Considerations
Some CSS properties affect what screen readers can perceive. Understanding which CSS hides content visually versus hiding it from assistive technology is essential for building inclusive experiences.
CSS and screen readers
display: none and visibility: hidden both hide content from screen readers. To hide something visually but keep it readable by screen readers, use the visually-hidden utility pattern.
- display: none, hidden from screen readers and layout
- visibility: hidden, hidden from screen readers, space preserved
- opacity: 0, invisible visually but still read by screen readers
- visually-hidden pattern, clip + position: absolute trick to hide visually but keep in the accessibility tree
- Generated content (::before, ::after) may or may not be read, never put meaningful text there
Visually hidden text
The label text is hidden visually but read by screen readers, the button has a meaningful accessible name.
/* The classic visually-hidden utility */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.icon-btn {
background: steelblue;
color: white;
border: none;
padding: 8px 14px;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}Color Contrast Requirements (WCAG)
WCAG (Web Content Accessibility Guidelines) defines minimum contrast ratios between text and its background to ensure readability for people with low vision or colour blindness.
WCAG contrast ratios
Contrast ratio measures the difference in luminance between foreground and background. 1:1 is identical (no contrast); 21:1 is black on white (maximum contrast).
- 4.5:1, AA normal text, minimum for body text under 18pt (or 14pt bold)
- 3:1, AA large text, minimum for large text (18pt+) and UI components
- 7:1, AAA normal text, enhanced level for body text
- Check contrast with browser DevTools, or tools like WebAIM Contrast Checker
- Colour alone should never be the only way to convey information
- Never rely on colour to differentiate errors from success, always add an icon or label too
Contrast ratio
The first line passes WCAG AA; the second fails.
.pass {
background: steelblue; /* contrast ~4.7:1 with white text */
color: white;
padding: 10px 14px;
border-radius: 4px;
margin-bottom: 8px;
}
.fail {
background: white;
color: #bbb; /* contrast ~1.6:1 with white background, fails AA */
padding: 10px 14px;
border: 1px solid #eee;
border-radius: 4px;
}Reduced Motion (@prefers-reduced-motion)
Some users experience nausea, dizziness, or seizures from fast-moving animations. Most operating systems have a "Reduce motion" setting. The @prefers-reduced-motion media query detects this preference so you can tone down or remove animations.
@prefers-reduced-motion
Best practice: write animations normally, then add a prefers-reduced-motion block at the end that removes or simplifies them for users who need it.
- prefers-reduced-motion: reduce, user has enabled the reduce motion OS setting
- prefers-reduced-motion: no-preference, user has not set a preference (default)
- You do not need to remove all animation, fades and simple opacity changes are usually acceptable
- Disable parallax effects, auto-playing carousels, and bouncing loaders for these users
- Test by enabling 'Reduce Motion' in your OS accessibility settings
@prefers-reduced-motion
The spinner animates normally by default but stops spinning when the user prefers reduced motion.
@keyframes spin {
to { transform: rotate(360deg); }
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #e2e8f0;
border-top-color: steelblue;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
/* Respect the user's motion preference */
@media (prefers-reduced-motion: reduce) {
.spinner {
animation: none;
border-top-color: steelblue;
opacity: 0.5;
}
}Dark Mode (@prefers-color-scheme)
@prefers-color-scheme detects whether the user's OS is in dark mode or light mode. Combined with CSS variables, it lets you implement a full dark mode theme without JavaScript.
@prefers-color-scheme
The cleanest approach is to define your theme colours as CSS variables in :root, then override them inside the dark mode media query. Every element using var() updates automatically.
- prefers-color-scheme: dark, user's OS is in dark mode
- prefers-color-scheme: light, user's OS is in light mode (default)
- Define light-mode variables in :root, then override in the dark media query
- CSS variables are the most maintainable approach, update the token, every element updates
- You can also let the browser handle it with color-scheme: light dark on :root
@prefers-color-scheme
The card changes colours based on the OS dark mode setting.
:root {
--bg: #ffffff;
--text: #1e293b;
--card-bg: #f1f5f9;
--border: #e2e8f0;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f172a;
--text: #e2e8f0;
--card-bg: #1e293b;
--border: #334155;
}
}
body { background: var(--bg); color: var(--text); padding: 20px; margin: 0; }
.card {
background: var(--card-bg);
border: 1px solid var(--border);
padding: 16px;
border-radius: 8px;
max-width: 300px;
}
.card h3 { margin: 0 0 8px; }
.card p { margin: 0; font-size: 0.9rem; }:focus-visible for Better UX
:focus-visible is a smarter version of :focus. It shows the focus ring only when it is actually useful for keyboard navigation and hides it for mouse clicks where it looks out of place.
:focus-visible
:focus-visible lets you remove the focus ring on mouse-clicked buttons while keeping it for keyboard users. It is the modern best practice for all interactive elements.
- :focus, fires for both mouse clicks and keyboard focus
- :focus-visible, fires only when the browser determines a visible indicator is needed (keyboard, not mouse)
- Use :focus-visible for buttons and links; use :focus for inputs (always show for text fields)
- Browser support is now excellent, all modern browsers support it
:focus-visible
Click the button with your mouse, no ring. Tab to it, the ring appears.
.btn {
background: steelblue;
color: white;
padding: 10px 24px;
border: none;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
outline: none;
}
/* Only show for keyboard users */
.btn:focus-visible {
box-shadow: 0 0 0 3px white, 0 0 0 5px steelblue;
}Logical Properties
Logical properties replace physical directions (left, right, top, bottom) with writing-mode-aware equivalents. They work correctly in right-to-left languages like Arabic and Hebrew without needing extra CSS overrides.
Logical properties
Logical properties use inline (horizontal in LTR) and block (vertical in LTR) axes instead of physical directions. In RTL layouts they automatically flip.
- margin-inline-start, left margin in LTR; right margin in RTL (replaces margin-left)
- margin-inline-end, right margin in LTR; left margin in RTL (replaces margin-right)
- padding-block-start, top padding in horizontal writing modes (replaces padding-top)
- padding-block-end, bottom padding (replaces padding-bottom)
- inset-inline-start, replaces left in positioned elements
- border-inline, border-block, margin-inline, padding-block, all have logical equivalents
Logical properties
margin-inline and padding-block instead of left/right/top/bottom, works in both LTR and RTL.
.note {
/* instead of: margin-left: 24px; margin-right: 24px; */
margin-inline: 24px;
/* instead of: padding-top: 12px; padding-bottom: 12px; */
padding-block: 12px;
/* instead of: padding-left: 16px; padding-right: 16px; */
padding-inline: 16px;
background: #f0f4f8;
border-inline-start: 4px solid steelblue;
border-radius: 4px;
}aspect-ratio Property
aspect-ratio locks the width-to-height ratio of an element. When you set one dimension, the other is calculated automatically. It replaces the old padding-top percentage hack.
aspect-ratio
aspect-ratio is perfect for video embeds, image placeholders, and any element that must maintain a specific shape regardless of its width.
- aspect-ratio: 16 / 9, standard widescreen video ratio
- aspect-ratio: 1, square (same as 1 / 1)
- aspect-ratio: 4 / 3, classic photo/screen ratio
- If both width and height are explicitly set, aspect-ratio is ignored
- Replaces the padding-top: 56.25% hack previously used for responsive video embeds
aspect-ratio
Both elements maintain their ratio regardless of the container width.
.video-container {
aspect-ratio: 16 / 9;
background: #1e293b;
color: white;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
margin-bottom: 12px;
width: 100%;
}
.square {
aspect-ratio: 1;
width: 80px;
background: steelblue;
color: white;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}Filter Effects
The filter property applies graphical effects to an element and its contents, blurring, colour adjustment, brightness, and more. Multiple filters can be stacked in one declaration.
filter
filter applies to the entire element including its children. For background blurring only, use backdrop-filter instead, it applies the effect to everything behind the element.
- blur(4px), blurs the element
- brightness(0.7), dims the element (1 = normal, 0 = black)
- contrast(1.5), increases contrast (1 = normal)
- grayscale(1), fully desaturates (0 = colour, 1 = grey)
- saturate(2), boosts colour saturation
- sepia(0.8), applies a warm sepia tone
- drop-shadow(2px 4px 8px rgba(0,0,0,0.3)), shadow that follows the element's shape (not the box)
filter effects
Four copies of the same image with different filter values applied.
.img {
border-radius: 6px;
margin-right: 10px;
margin-bottom: 10px;
display: inline-block;
}
.original { filter: none; }
.gray { filter: grayscale(1); }
.bright { filter: brightness(1.4) saturate(1.3); }
.blurred { filter: blur(3px); }Browser Compatibility and Feature Detection
Not every CSS feature is available in every browser. The @supports rule lets you check whether a browser supports a property before applying styles that depend on it.
@supports and feature detection
@supports applies a block of CSS only when the browser supports the given property and value. It is the CSS equivalent of a feature detection check in JavaScript.
- @supports (display: grid), styles inside only apply if grid is supported
- @supports not (display: grid), fallback styles for browsers without grid
- @supports (container-type: inline-size), check for container query support
- caniuse.com, the definitive resource for checking browser support of CSS features
- Most modern CSS features (grid, flexbox, custom properties, clamp) are safe to use in all current browsers
- Progressive enhancement: build a working baseline first, then layer in modern features
@supports
Grid layout is used when supported; flexbox is the fallback for older browsers.
/* Fallback for browsers without grid */
.layout {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.item {
flex: 1 1 100px;
background: steelblue;
color: white;
padding: 16px;
border-radius: 4px;
text-align: center;
}
/* Progressive enhancement: use grid when available */
@supports (display: grid) {
.layout {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
}Knowledge Check
1. What is the minimum contrast ratio required by WCAG AA for normal body text?
2. What does @prefers-reduced-motion: reduce signal?
3. What is the difference between :focus and :focus-visible?
4. Which CSS property should you use instead of margin-left for international/logical layouts?
5. What does filter: grayscale(1) do?
6. Which media query detects the user's dark mode preference?