sveltelean Primitives
Versionv0.2.0 GitHub

Example

Four slides

Dialog

A modal from <dialog> and two buttons with commandfor.

Read the dialog page

Popover

The top layer, light dismiss and focus return from one attribute.

Read the popover page

Accordion

One open item at a time from a shared name on details elements.

Read the accordion page

Date field

Segmented entry, validation and a picker in one input element.

Read the date field page
Tier 0: no script. In Chrome the buttons and markers under the slides are generated by the browser from the stylesheet; in Firefox and Safari the row scrolls and snaps, with its scrollbar, and the arrow keys scroll the focused viewport.

Why this implementation exists

Most of a carousel is scrolling: a row wider than its box, moved by touch, trackpad or keys, stopping on a slide. Scroll snapping does that in every browser, and a focusable viewport gives the keyboard the same. Component libraries rebuild it with transforms and pointer handlers and then have to rebuild momentum, snapping and keyboard support too.

The rest, buttons and markers, is now CSS as well. ::scroll-button() asks the browser for a button that scrolls the viewport and disables itself at the end; ::scroll-marker asks for one marker per slide in a group the browser exposes as tabs. Chrome ships them; Firefox and Safari do not yet, and there the carousel stays a snapping row. Svelte Lean adds no script to fill that gap: the content is reachable either way.

The browser owns

  • scrolling, snapping, touch and trackpad swipes, arrow keys on the focused viewport
  • scrolling a focused element inside a slide into view
  • in Chromium: the previous and next buttons, one marker per slide, their roles, names, keyboard and disabled state

Svelte Lean owns

  • carousel.css: the snapping row, markers requested as tabs, the button and marker layout, the drawn chevrons, the faded button and the filled marker
  • the contract: roles, labels, the names of the buttons, and why there is no autoplay
  • documentation

Usage

The markup needs no package. Install @svelte-lean/styles for the stylesheet; @svelte-lean/primitives adds the typed contract and nothing at runtime.

stylesheets
import '@svelte-lean/styles/carousel.css';

Name the root with aria-label, give the viewport tabindex="0", and give each slide role="group", aria-roledescription="slide" and its position as a label in the page language. Name the generated buttons with data-slean-previous and data-slean-next on the viewport; without them the browser names them in the language of its own interface.

The stylesheet sets container-type: scroll-state on the viewport and the slides to fade a button that cannot scroll and fill the marker of the snapped slide. If your CSS sets another container type on a slide, keep scroll-state in the list.

Anatomy

PartElementdata-slean-partRequiredNotes
root<section data-slean="carousel" aria-roledescription="carousel">–yesNamed with aria-label or aria-labelledby.
viewport<div tabindex="0">viewportyesThe scroll container. data-slean-previous and data-slean-next name the generated buttons.
slide<div role="group" aria-roledescription="slide">slideyesaria-label with its position, such as "1 of 4"; its marker takes that name.

Runtime profile

Tier 0: the carousel has no behavior module, the Vite plugin maps carousel to no module, and the native-only fixture proves the path ships no Svelte Lean JavaScript. The buttons and markers are pseudo-elements the browser creates from carousel.css.

Accessibility contract

  • The root is a region named by its label with the role description carousel; each slide is a group with the role description slide and a label such as 1 of 4.
  • The viewport is focusable, so ArrowLeft and ArrowRight scroll it in every browser; Tab moves into the slides and the browser scrolls a focused link into view.
  • In Chrome the markers are exposed as tabs named after the slides, the current one selected, and the arrow keys move between them; the buttons are named buttons that the browser disables at the ends. The stylesheet asks for tabs with scroll-marker-group: after tabs; the default mode, links, is exposed by Chrome 154 as a navigation of links.
  • No autoplay: moving content needs a pause control and a stop on hover and focus, which would make the carousel a Tier 2 behavior. No live region either, since scrolling changes no content.

Keyboard

KeyWhenResult
ArrowLeft/ArrowRightfocus on the viewportScrolls; snapping settles on a slide (native, every browser)
Enter/Spacefocus on a generated button (Chromium)Scrolls one page of the viewport (native)
ArrowLeft/ArrowRightfocus on a marker (Chromium)Moves to the neighbouring marker and scrolls to its slide (native)

Platform features

FeatureBaselineOutside the target
Scroll snapBaseline 2020, widely availableNot applicable within the support policy
::scroll-button(), ::scroll-marker, ::scroll-marker-groupChrome 135; not in Firefox or Safari at the time of writingNo buttons and no markers; the viewport keeps its scrollbar
scroll-marker-group: after tabsChrome 151 parses the keyword; Chrome 154 exposes the default, links, as a navigation of linksThe declaration is dropped and scroll-marker-group: after applies
Container scroll-state queriesChrome 133Not used: the buttons and markers they style do not exist there
:dir()Baseline 2023, widely availableNot applicable within the support policy

Without JavaScript

Fully functional, including the generated buttons and markers in Chrome.

Server rendering

Static HTML. The first slide is in view on load.

Before hydration

Nothing is attached; scrolling, snapping and the generated controls work before hydration.

Styling

carousel.css lays out the row and its snap points, hides the scrollbar where the buttons and markers exist, places the buttons at the ends of the marker row, draws their chevrons as two background strokes in a rotated circle (mirrored under :dir(rtl)), and uses scroll-state container queries for the faded button and the filled, wider marker. The tokens it reads:

TokenDefault (light)Applies to
--slean-fgvar(--slean-neutral-12)color
--slean-space-41remgap
--slean-radius-lg0.875remborder-radius
--slean-space-30.75remmargin-block-start
--slean-radius-full9999pxborder-radius
--slean-border-strongvar(--slean-neutral-8)background
--slean-duration-fast100mstransition
--slean-easecubic-bezier(0.2, 0, 0, 1)transition
--slean-accentoklch(54% 0.19 258)background-color
--slean-icon-chevron-lefturl("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M10 4L6 8l4 4' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E")mask
--slean-icon-chevron-righturl("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M6 4l4 4-4 4' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E")mask-image

State selectors the stylesheet targets, all from the platform or ARIA: ::scroll-button(*), ::scroll-button(inline-end), ::scroll-button(inline-start), ::scroll-marker, ::scroll-marker-group, :dir(rtl).

Variant attributes: data-slean-previous; data-slean-next.

Compatibility notes

Scroll snapping is Baseline 2020 and widely available. ::scroll-button(), ::scroll-marker and ::scroll-marker-group are in Chrome 135 and not in Firefox or Safari at the time of writing; container scroll-state queries, used only to style them, are in Chrome 133. Elsewhere the viewport keeps its scrollbar and the arrow keys, touch and trackpad move it.

Examples

Several slides per view

A slide is as wide as the viewport by default. Set a narrower flex-basis on the slides in the application's CSS; snapping still aligns a slide's start, and the generated buttons scroll one viewport width at a time.

app.css
/* Application CSS: slides narrower than the viewport. Snapping still aligns a slide's start,
 * and the generated buttons scroll one viewport width at a time. */
.projects [data-slean-part='slide'] {
	flex-basis: min(18rem, 80%);
}

Testing

Source