SSS - Single Standard for Sliders

SSS - Single Standard for Sliders
Standards

35

10 min.

Definitions

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.

Structure and Semantics

  • Native <button> elements are used to move between slides. Links are reserved for navigation to another address, not for controlling a carousel.
  • In a finite carousel, the previous button is disabled at the first snap point and the next button at the last. In a looping carousel, both remain available when the component supports wrapping at the boundaries.
  • The native 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.
  • Arrow controls and slide pickers are hidden when all items fit in the viewport and navigation between positions is unnecessary. The decision is based on actual overflow, not only on the total slide count.
  • Overflow is checked at initialization and whenever the viewport or content changes size. Measuring the container and slide track, for example with ResizeObserver, is suitable; listening only to window.resize is insufficient because element dimensions can change without a window resize.
  • Overflow is checked again after data updates, font loading, or responsive layout changes whenever they affect slide dimensions.
  • In Embla, previous and next button states are synchronized with canScrollPrev() and canScrollNext() at initialization and on select and reInit events. The buttons are placed outside the viewport node that handles dragging.

Responsive behavior

  • The number of visible slides and the 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.
  • On narrow screens, showing part of the adjacent slide, for example 10–20 px, can indicate that more content is available. This is a design recommendation, not a fixed requirement; the visible portion depends on the container width, spacing, and writing direction.
  • A partially visible slide must not overlap controls, crop important text, or create horizontal overflow on the page itself.
  • Movement direction, item order, and navigation labels must match the interface’s reading direction. In RTL interfaces, the previous/next logic and the visual direction of the arrows are verified.

Accessibility and Interactivity

Semantics and accessible names

  • The carousel container uses 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.
  • The container receives an accessible name through a visible heading and 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.
  • Each slide uses 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.
  • Navigation buttons have accessible names, for example aria-label="Previous slide" and aria-label="Next slide". Visible button text can provide the accessible name without aria-label.
  • Buttons for choosing a slide or slide group are grouped and the group receives an accessible name. The active picker uses semantics appropriate to the selected pattern: 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.
  • Slides that are visually outside the viewport must not leave focusable elements at positions that cannot be seen. 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.

Pagination dots

  • Dots are optional navigation controls, not a required part of every carousel. They are useful when direct selection of a position helps and the number of positions does not add an excessive number of stops to the Tab sequence.
  • Each dot represents one scroll position (snap point), not necessarily one slide. If a step advances a group of slides, the dot’s accessible name describes that group. In Embla, positions are obtained from scrollSnapList() and navigation uses scrollTo(index).
  • The active dot is visually distinct and programmatically identified according to the selected pattern. For a group of ordinary picker buttons, the active button uses 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.
  • Each dot button has an accessible name describing its purpose and position, such as aria-label="Go to position 2 of 5". For grouped slides, the name identifies the range or content of the group.
  • Dots are native <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.
  • In Embla, the dots container is placed outside the viewport passed to useEmblaCarousel. The viewport handles pointer and drag input, which can interfere with clicking buttons placed inside it.
  • Dot state is synchronized with Embla at initialization and on select and reInit. After the container width or slide set changes, the number of snap points and dots may change.

Keyboard, dragging, and automatic advancement

  • All pointer- or touch-operated actions are also available from the keyboard. Navigation uses buttons and the standard Tab order; activating an arrow or picker must not unexpectedly move focus.
  • When pointer dragging is supported, the drag surface may use a grab cursor at rest and grabbing while pressed and dragged. This visual cue does not replace navigation buttons, keyboard operation, or touch input.
  • Horizontal swiping must not interfere with vertical page scrolling, text selection, or nested interactive elements.
  • For a manually operated carousel, the slide area may use aria-live="polite" so changes are announced without interrupting current speech. An automatically rotating carousel uses aria-live="off" to avoid continuous announcements.
  • An automatically advancing carousel provides an accessible stop/resume button. Rotation stops when any element in the carousel receives keyboard focus and while the pointer is hovering over it; it does not resume without an explicit user action.
  • Animation respects the 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 and Performance

Height and layout stability

  • 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.
  • When a stable height is important to the layout, space is reserved with an aspect ratio, 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.
  • For slides of varying height, the carousel may adapt to the active slide when that behavior is intentional and does not create unexpected movement in surrounding content. Height is checked with real content, including long headings and localized versions.

Animation and scrolling

  • 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.
  • CSS Scroll Snap can be used for carousels built on native horizontal scrolling. This preserves expected browser scrolling behavior and interaction with touch and assistive technologies; behavior is verified in target browsers.

Image loading

  • 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.
  • Images have width and height or a known aspect ratio. This reserves space before loading and reduces the risk of layout shift.
  • When 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.

Verification

  • Previous and next buttons have the correct states in finite and looping modes; disabled is exposed to assistive technologies and visually apparent.
  • Arrows and pickers are hidden only when navigation is unnecessary; overflow is checked after initialization and when the container or content changes size.
  • The carousel works with keyboard, pointer, and touch input; focus is not lost or moved unexpectedly.
  • The container, slides, arrows, and dots have clear accessible names; the current position uses the selected semantics, and automatic rotation can be stopped.
  • The number of dots matches the number of snap positions, and their state stays synchronized after dragging, selecting a position, and resizing; target size or spacing is checked against WCAG 2.2 SC 2.5.8.
  • aria-live behavior in manual and automatic modes, the reduced-motion setting, and slides outside the visible area are verified.
  • On mobile sizes, the adjacent-slide peek, vertical page scrolling, and absence of page-level horizontal overflow are checked.
  • Images reserve space before loading; lazy loading does not defer the initial critical slide.
  • Animation smoothness and layout shift are checked on target devices and browsers, not inferred solely from the presence of transform or will-change.

Examples

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.

Similar categories:

Similar articles

  • Anatomy of a React component

    My vision for how every React component should look

    421

    8 min.

  • SST - Single Standard for Tabs

    The SST standard defines requirements for the semantics, accessibility, and logic of tabs

    62

    7 min.

  • SSF-U - Single Standard for Fullscreen

    The SSF-U standard defines requirements for the semantics, accessibility, and logic of fullscreen

    248

    2 min.

  • SSA - Single Standard for Accordion

    The SSA standard defines requirements for the semantics, accessibility, and logic of accordion

    258

    2 min.

  • Bad Practices for Websites

    An Analysis of Critical Web Design Mistakes. Why Sliders, Autoplay, and Slow-Loading Pages Reduce Conversion Rates and Rankings on Google

    232

    2 min.

  • SSP - Single Standard for Pagination

    The SSP standard defines requirements for the semantics, accessibility, and logic of pagination

    240

    1 min.

  • All articles

Contact me