Anatomy of a React component
My vision for how every React component should look
421
8 min.
The SSS standard defines requirements for the semantics, accessibility, and logic of sliders.
Requirements depend on the component’s behavior: finite or looping navigation, manual or automatic advancement, and the number of slides visible at once.
Embla Carousel with its React wrapper is used in the implementation examples. The library is illustrative, not a required dependency; the example API targets Embla v8.
<button> elements are used to move between slides. Links are reserved for navigation to another address, not for controlling a carousel.disabled attribute conveys an unavailable button state. Its appearance must also differ from the enabled state; for example, opacity and a not-allowed cursor may be changed. Visual styling alone does not expose the state to assistive technologies.ResizeObserver, is suitable; listening only to window.resize is insufficient because element dimensions can change without a window resize.canScrollPrev() and canScrollNext() at initialization and on select and reInit events. The buttons are placed outside the viewport node that handles dragging.gap are set according to the component’s available width. Media queries or container queries can be used, depending on whether the layout responds to the viewport or its parent container.role="region" when it is a significant page area, or role="group" when a separate landmark is unnecessary. aria-roledescription="carousel" identifies the component type.aria-labelledby, or through aria-label when there is no visible heading. When aria-roledescription is used, the word “carousel” need not be repeated in the accessible name.role="group" and aria-roledescription="slide", and receives a meaningful name from its heading or, when it has no separate heading, from its position, such as “2 of 5.” When aria-roledescription="slide" is present, the word “slide” is not repeated in the name.aria-label="Previous slide" and aria-label="Next slide". Visible button text can provide the accessible name without aria-label.aria-disabled="true" for a group of picker buttons following APG, or aria-selected="true" for a tabs implementation. aria-current="true" is not automatically applied to the slide container; the current position is conveyed on the control using the semantics of the chosen pattern.aria-hidden="true" is used only when the content is consistently removed from both the accessibility tree and focus navigation. A partially visible slide is not hidden.scrollSnapList() and navigation uses scrollTo(index).aria-disabled="true" as in the APG pattern; a tabs implementation requires the complete tabs pattern, including aria-selected and keyboard interaction. Color or opacity alone is insufficient.aria-label="Go to position 2 of 5". For grouped slides, the name identifies the range or content of the group.<button> elements with a visible :focus-visible state. Their pointer target must meet WCAG 2.2 SC 2.5.8: at least 24 × 24 CSS px, or an applicable exception such as sufficient spacing or an equivalent control.useEmblaCarousel. The viewport handles pointer and drag input, which can interfere with clicking buttons placed inside it.select and reInit. After the container width or slide set changes, the number of snap points and dots may change.grab cursor at rest and grabbing while pressed and dragged. This visual cue does not replace navigation buttons, keyboard operation, or touch input.aria-live="polite" so changes are announced without interrupting current speech. An automatically rotating carousel uses aria-live="off" to avoid continuous announcements.prefers-reduced-motion system setting: when reduced motion is requested, transitions are shortened or disabled unless they are needed to understand a state change.height: auto is not prohibited as a general rule. Automatic height does not by itself cause a jump; layout shift occurs when the carousel’s height changes with its content without that behavior being accounted for.min-height, or a consistent slide height. A fixed height must not clip content when text grows, the language changes, or the page is zoomed.transform and, where appropriate, opacity are preferred for movement animations because changing left or margin can trigger layout recalculation. The choice is verified through profiling; no CSS property guarantees smoothness on its own.will-change: transform is not added to every slide by default. It is used only when a performance issue has been confirmed, because reserving resources for many layers continuously can reduce performance.loading="lazy" is not set on images visible during the initial load, including the critical first slide. Lazy loading can be used for images that start outside the viewport.loading="lazy" does not specify an exact distance of one or two slides: the browser chooses when to start loading based on proximity to the viewport and other conditions. Loading the next slide earlier requires a separate decision supported by measurements and the network resource budget.width and height or a known aspect ratio. This reserves space before loading and reduces the risk of layout shift.srcset and sizes are used, their values reflect the responsive image width within the slide. Full-size images should not be loaded for thumbnails without a need.disabled is exposed to assistive technologies and visually apparent.aria-live behavior in manual and automatic modes, the reduced-motion setting, and slides outside the visible area are verified.transform or will-change.Manually operated Embla carousel with navigation and dots:
'use client'
import { useCallback, useEffect, useState } from 'react'
import useEmblaCarousel from 'embla-carousel-react'
import type { EmblaCarouselType } from 'embla-carousel'
const slides = [
{ id: 'article-1', title: 'First article', image: '/images/article-1.webp' },
{ id: 'article-2', title: 'Second article', image: '/images/article-2.webp' },
{ id: 'article-3', title: 'Third article', image: '/images/article-3.webp' },
]
export const ArticleCarousel = () => {
const [emblaRef, emblaApi] = useEmblaCarousel({ loop: false, slidesToScroll: 1 })
const [scrollSnaps, setScrollSnaps] = useState<number[]>([])
const [selectedSnap, setSelectedSnap] = useState(0)
const onSelect = useCallback((api: EmblaCarouselType) => {
setSelectedSnap(api.selectedScrollSnap())
}, [])
const onReInit = useCallback((api: EmblaCarouselType) => {
setScrollSnaps(api.scrollSnapList())
setSelectedSnap(api.selectedScrollSnap())
}, [])
useEffect(() => {
if (!emblaApi) return
onReInit(emblaApi)
emblaApi.on('select', onSelect)
emblaApi.on('reInit', onReInit)
return () => {
emblaApi.off('select', onSelect)
emblaApi.off('reInit', onReInit)
}
}, [emblaApi, onReInit, onSelect])
return (
<section
className='embla'
role='region'
aria-roledescription='carousel'
aria-labelledby='featured-title'
>
<h2 id='featured-title'>Featured articles</h2>
<div
className='embla__viewport'
ref={emblaRef}
aria-live='polite'
aria-atomic='false'
>
<div className='embla__container'>
{slides.map((slide) => (
<article
className='embla__slide'
key={slide.id}
role='group'
aria-roledescription='slide'
aria-labelledby={`slide-title-${slide.id}`}
>
<img
src={slide.image}
width='960'
height='640'
alt={`Cover image: ${slide.title}`}
/>
<h3 id={`slide-title-${slide.id}`}>{slide.title}</h3>
</article>
))}
</div>
</div>
{scrollSnaps.length > 1 && (
<div className='embla__controls'>
<button
type='button'
aria-label='Previous slide'
disabled={!emblaApi?.canScrollPrev()}
onClick={() => emblaApi?.scrollPrev()}
>
<span aria-hidden='true'>←</span>
</button>
<button
type='button'
aria-label='Next slide'
disabled={!emblaApi?.canScrollNext()}
onClick={() => emblaApi?.scrollNext()}
>
<span aria-hidden='true'>→</span>
</button>
<div className='embla__dots' role='group' aria-label='Choose carousel position'>
{scrollSnaps.map((_, index) => {
const isSelected = index === selectedSnap
return (
<button
className='embla__dot'
type='button'
key={index}
aria-label={`Go to position ${index + 1} of ${scrollSnaps.length}`}
aria-disabled={isSelected}
onClick={() => {
if (!isSelected) emblaApi?.scrollTo(index)
}}
>
<span aria-hidden='true' />
</button>
)
})}
</div>
</div>
)}
</section>
)
};The example uses manual navigation, so the slide area has aria-live="polite". The controls are outside .embla__viewport so dragging does not interfere with button activation. scrollSnapList() provides the dots for each scroll position, and their state is updated on select and reInit. The loading attribute is omitted because the number of images visible on initial load depends on the responsive layout; in a production component, initially visible images are not deferred, while images outside the initial viewport may be lazy-loaded. When using pnpm and importing EmblaCarouselType, embla-carousel must also be added as a direct development dependency.
Responsive Embla layout with a partial preview of the next card and an accessible dot target:
.embla__viewport {
overflow: hidden;
cursor: grab;
touch-action: pan-y pinch-zoom;
}
.embla__viewport:active {
cursor: grabbing;
}
.embla__container {
display: flex;
gap: 12px;
}
.embla__slide {
flex: 0 0 calc(100% - 28px);
min-width: 0;
}
.embla__dot {
display: inline-grid;
min-width: 24px;
min-height: 24px;
place-items: center;
border: 0;
background: transparent;
cursor: pointer;
}
.embla__dot span {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
}
.embla__dot[aria-disabled='true'] span {
width: 12px;
height: 12px;
}
.embla__dot:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
@media (min-width: 768px) {
.embla__slide {
flex-basis: calc((100% - 24px) / 3);
}
}With the first card set to calc(100% - 28px) and a 12px gap, about 16 px of the next card remains visible in the viewport. This is one layout example: the number of visible cards, gap, adjacent-slide preview, and dot hit area are checked against the actual container width. When using Embla v8, API examples are verified against the documentation for that major version.
My vision for how every React component should look
421
8 min.
The SST standard defines requirements for the semantics, accessibility, and logic of tabs
62
7 min.
The SSF-U standard defines requirements for the semantics, accessibility, and logic of fullscreen
248
2 min.
The SSA standard defines requirements for the semantics, accessibility, and logic of accordion
258
2 min.
An Analysis of Critical Web Design Mistakes. Why Sliders, Autoplay, and Slow-Loading Pages Reduce Conversion Rates and Rankings on Google
232
2 min.
The SSP standard defines requirements for the semantics, accessibility, and logic of pagination
240
1 min.