A reliable foundation
The scroll port settles each card into a predictable position.
Combine scroll snap with ::scroll-button(), ::scroll-marker, and ::scroll-marker-group to explore a native CSS carousel. Compare links and tabs modes, navigation, accessibility and fallback in the guide.
Drag, use the wheel, keyboard, or the controls generated by the browser.
Your browser does not support the selected mode. Positional markers retain this version’s own behavior; the code shows the requested mode.
Your browser does not render the experimental buttons or markers yet. Scrolling and scroll snap remain available as the fallback.
<ul class="carousel" aria-label="Native CSS carousel examples" tabindex="0">
<li
class="slide"
tabindex="-1"
data-marker-label="A reliable foundation"
style="--slide-accent: #ffb38a"
>
<article class="slide-card">
<p class="slide-eyebrow">01 · Scroll snap</p>
<h2>A reliable foundation</h2>
<p>The scroll port settles each card into a predictable position.</p>
</article>
</li>
<li
class="slide"
tabindex="-1"
data-marker-label="Controls from CSS"
style="--slide-accent: #72d6c9"
>
<article class="slide-card">
<p class="slide-eyebrow">02 · Scroll buttons</p>
<h2>Controls from CSS</h2>
<p>The browser creates real previous and next buttons without extra markup.</p>
</article>
</li>
<li
class="slide"
tabindex="-1"
data-marker-label="Native pagination"
style="--slide-accent: #b6a8ff"
>
<article class="slide-card">
<p class="slide-eyebrow">03 · Scroll markers</p>
<h2>Native pagination</h2>
<p>Every slide generates a marker that navigates to its own scroll target.</p>
</article>
</li>
<li
class="slide"
tabindex="-1"
data-marker-label="State without scripts"
style="--slide-accent: #f7a6c4"
>
<article class="slide-card">
<p class="slide-eyebrow">04 · Target current</p>
<h2>State without scripts</h2>
<p>The current marker follows the visible slide through :target-current.</p>
</article>
</li>
</ul><ul class="flex flex-row gap-[16px] [inline-size:min(100%,_56rem)] m-0 p-0 overflow-x-auto [overscroll-behavior-inline:contain] scroll-smooth motion-reduce:scroll-auto [scroll-snap-type:inline_mandatory] [scroll-marker-group:after] supports-[scroll-marker-group:after_links]:[scroll-marker-group:after_links] [anchor-name:--carousel] [&::scroll-marker-group]:flex [&::scroll-marker-group]:flex-row [&::scroll-marker-group]:items-center [&::scroll-marker-group]:justify-center [&::scroll-marker-group]:gap-3 [&::scroll-marker-group]:pt-5 [&::scroll-marker-group]:pb-2 [&::scroll-button(*)]:absolute [&::scroll-button(*)]:[position-anchor:--carousel] [&::scroll-button(*)]:z-[1] [&::scroll-button(*)]:grid [&::scroll-button(*)]:place-items-center [&::scroll-button(*)]:size-10 [&::scroll-button(*)]:border-0 [&::scroll-button(*)]:rounded-full [&::scroll-button(*)]:bg-[#172033] [&::scroll-button(*)]:text-white [&::scroll-button(*)]:[font:inherit] [&::scroll-button(*)]:text-[1.15rem] [&::scroll-button(*)]:shadow-[0_0.75rem_2rem_rgb(0_0_0/0.24)] [&::scroll-button(*)]:cursor-pointer [&::scroll-button(*)]:transition-[background-color,opacity,scale] [&::scroll-button(*)]:duration-200 [&::scroll-button(*):hover]:bg-[#7c5cff] [&::scroll-button(*):hover]:scale-[1.08] [&::scroll-button(*):focus-visible]:[outline:3px_solid_color-mix(in_oklch,_#7c5cff_42%,_transparent)] [&::scroll-button(*):focus-visible]:outline-offset-3 [&::scroll-button(*):disabled]:opacity-[0.35] [&::scroll-button(*):disabled]:cursor-not-allowed [&::scroll-button(inline-start)]:content-['←'] [&::scroll-button(inline-start)]:[position-area:left_center] [&::scroll-button(inline-start)]:[translate:60%] [&::scroll-button(inline-end)]:content-['→'] [&::scroll-button(inline-end)]:[position-area:right_center] [&::scroll-button(inline-end)]:[translate:-60%]" aria-label="Native CSS carousel examples" tabindex="0">
<li class="list-none flex-[0_0_84%] [scroll-snap-align:center] [scroll-snap-stop:always] [&::scroll-marker]:[content:''_/_attr(data-marker-label)] [&::scroll-marker]:[inline-size:20px] [&::scroll-marker]:[block-size:20px] [&::scroll-marker]:border-2 [&::scroll-marker]:border-[#64748b] [&::scroll-marker]:rounded-full [&::scroll-marker]:bg-white [&::scroll-marker]:cursor-pointer [&::scroll-marker]:transition-[background-color,border-color,scale] [&::scroll-marker]:duration-200 [&::scroll-marker:hover]:border-[#7c5cff] [&::scroll-marker:hover]:bg-[color-mix(in_oklch,#7c5cff_18%,#fff)] [&::scroll-marker:focus-visible]:[outline:3px_solid_color-mix(in_oklch,_#7c5cff_38%,_transparent)] [&::scroll-marker:focus-visible]:outline-offset-2 [&::scroll-marker:target-current]:border-[#172033] [&::scroll-marker:target-current]:bg-[#172033] [&::scroll-marker:target-current]:shadow-[0_0_0_2px_#fff,0_0_0_4px_#172033] [&::scroll-marker:target-current]:scale-110" tabindex="-1" data-marker-label="A reliable foundation">
<article class="box-border grid content-end gap-2 min-block-[18rem] p-[clamp(1.5rem,5vw,3rem)] border border-[color-mix(in_oklch,#ffb38a_72%,#172033)] rounded-[1.5rem] bg-[#ffb38a] text-[#172033] *:m-0 [&_h2]:max-inline-[16ch] [&_h2]:text-[clamp(1.75rem,6vw,3.5rem)] [&_h2]:leading-none [&_h2]:text-balance">
<p class="text-[rgb(23_32_51/0.72)] font-bold tracking-[0.08em] uppercase">01 · Scroll snap</p>
<h2>A reliable foundation</h2>
<p>The scroll port settles each card into a predictable position.</p>
</article>
</li>
<li class="list-none flex-[0_0_84%] [scroll-snap-align:center] [scroll-snap-stop:always] [&::scroll-marker]:[content:''_/_attr(data-marker-label)] [&::scroll-marker]:[inline-size:20px] [&::scroll-marker]:[block-size:20px] [&::scroll-marker]:border-2 [&::scroll-marker]:border-[#64748b] [&::scroll-marker]:rounded-full [&::scroll-marker]:bg-white [&::scroll-marker]:cursor-pointer [&::scroll-marker]:transition-[background-color,border-color,scale] [&::scroll-marker]:duration-200 [&::scroll-marker:hover]:border-[#7c5cff] [&::scroll-marker:hover]:bg-[color-mix(in_oklch,#7c5cff_18%,#fff)] [&::scroll-marker:focus-visible]:[outline:3px_solid_color-mix(in_oklch,_#7c5cff_38%,_transparent)] [&::scroll-marker:focus-visible]:outline-offset-2 [&::scroll-marker:target-current]:border-[#172033] [&::scroll-marker:target-current]:bg-[#172033] [&::scroll-marker:target-current]:shadow-[0_0_0_2px_#fff,0_0_0_4px_#172033] [&::scroll-marker:target-current]:scale-110" tabindex="-1" data-marker-label="Controls from CSS">
<article class="box-border grid content-end gap-2 min-block-[18rem] p-[clamp(1.5rem,5vw,3rem)] border border-[color-mix(in_oklch,#72d6c9_72%,#172033)] rounded-[1.5rem] bg-[#72d6c9] text-[#172033] *:m-0 [&_h2]:max-inline-[16ch] [&_h2]:text-[clamp(1.75rem,6vw,3.5rem)] [&_h2]:leading-none [&_h2]:text-balance">
<p class="text-[rgb(23_32_51/0.72)] font-bold tracking-[0.08em] uppercase">02 · Scroll buttons</p>
<h2>Controls from CSS</h2>
<p>The browser creates real previous and next buttons without extra markup.</p>
</article>
</li>
<li class="list-none flex-[0_0_84%] [scroll-snap-align:center] [scroll-snap-stop:always] [&::scroll-marker]:[content:''_/_attr(data-marker-label)] [&::scroll-marker]:[inline-size:20px] [&::scroll-marker]:[block-size:20px] [&::scroll-marker]:border-2 [&::scroll-marker]:border-[#64748b] [&::scroll-marker]:rounded-full [&::scroll-marker]:bg-white [&::scroll-marker]:cursor-pointer [&::scroll-marker]:transition-[background-color,border-color,scale] [&::scroll-marker]:duration-200 [&::scroll-marker:hover]:border-[#7c5cff] [&::scroll-marker:hover]:bg-[color-mix(in_oklch,#7c5cff_18%,#fff)] [&::scroll-marker:focus-visible]:[outline:3px_solid_color-mix(in_oklch,_#7c5cff_38%,_transparent)] [&::scroll-marker:focus-visible]:outline-offset-2 [&::scroll-marker:target-current]:border-[#172033] [&::scroll-marker:target-current]:bg-[#172033] [&::scroll-marker:target-current]:shadow-[0_0_0_2px_#fff,0_0_0_4px_#172033] [&::scroll-marker:target-current]:scale-110" tabindex="-1" data-marker-label="Native pagination">
<article class="box-border grid content-end gap-2 min-block-[18rem] p-[clamp(1.5rem,5vw,3rem)] border border-[color-mix(in_oklch,#b6a8ff_72%,#172033)] rounded-[1.5rem] bg-[#b6a8ff] text-[#172033] *:m-0 [&_h2]:max-inline-[16ch] [&_h2]:text-[clamp(1.75rem,6vw,3.5rem)] [&_h2]:leading-none [&_h2]:text-balance">
<p class="text-[rgb(23_32_51/0.72)] font-bold tracking-[0.08em] uppercase">03 · Scroll markers</p>
<h2>Native pagination</h2>
<p>Every slide generates a marker that navigates to its own scroll target.</p>
</article>
</li>
<li class="list-none flex-[0_0_84%] [scroll-snap-align:center] [scroll-snap-stop:always] [&::scroll-marker]:[content:''_/_attr(data-marker-label)] [&::scroll-marker]:[inline-size:20px] [&::scroll-marker]:[block-size:20px] [&::scroll-marker]:border-2 [&::scroll-marker]:border-[#64748b] [&::scroll-marker]:rounded-full [&::scroll-marker]:bg-white [&::scroll-marker]:cursor-pointer [&::scroll-marker]:transition-[background-color,border-color,scale] [&::scroll-marker]:duration-200 [&::scroll-marker:hover]:border-[#7c5cff] [&::scroll-marker:hover]:bg-[color-mix(in_oklch,#7c5cff_18%,#fff)] [&::scroll-marker:focus-visible]:[outline:3px_solid_color-mix(in_oklch,_#7c5cff_38%,_transparent)] [&::scroll-marker:focus-visible]:outline-offset-2 [&::scroll-marker:target-current]:border-[#172033] [&::scroll-marker:target-current]:bg-[#172033] [&::scroll-marker:target-current]:shadow-[0_0_0_2px_#fff,0_0_0_4px_#172033] [&::scroll-marker:target-current]:scale-110" tabindex="-1" data-marker-label="State without scripts">
<article class="box-border grid content-end gap-2 min-block-[18rem] p-[clamp(1.5rem,5vw,3rem)] border border-[color-mix(in_oklch,#f7a6c4_72%,#172033)] rounded-[1.5rem] bg-[#f7a6c4] text-[#172033] *:m-0 [&_h2]:max-inline-[16ch] [&_h2]:text-[clamp(1.75rem,6vw,3.5rem)] [&_h2]:leading-none [&_h2]:text-balance">
<p class="text-[rgb(23_32_51/0.72)] font-bold tracking-[0.08em] uppercase">04 · Target current</p>
<h2>State without scripts</h2>
<p>The current marker follows the visible slide through :target-current.</p>
</article>
</li>
</ul>.carousel {
display: flex;
flex-direction: row;
gap: 16px;
inline-size: min(100%, 56rem);
margin: 0;
padding: 0;
overflow-x: auto;
overscroll-behavior-inline: contain;
scroll-behavior: smooth;
scroll-snap-type: inline mandatory;
scroll-marker-group: after;
scroll-marker-group: after links;
anchor-name: --carousel;
}
.carousel > .slide {
list-style: none;
flex: 0 0 84%;
scroll-snap-align: center;
scroll-snap-stop: always;
}
.slide-card {
box-sizing: border-box;
display: grid;
align-content: end;
gap: 0.5rem;
min-block-size: 18rem;
padding: clamp(1.5rem, 5vw, 3rem);
border: 1px solid color-mix(in oklch, var(--slide-accent) 72%, #172033);
border-radius: 1.5rem;
background: var(--slide-accent);
color: #172033;
}
.slide-card > * {
margin: 0;
}
.slide-card h2 {
max-inline-size: 16ch;
font-size: clamp(1.75rem, 6vw, 3.5rem);
line-height: 1;
text-wrap: balance;
}
.slide-eyebrow {
color: rgb(23 32 51 / 0.72);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.carousel::scroll-marker-group {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding-block: 1.25rem 0.5rem;
}
.carousel > .slide::scroll-marker {
content: "" / attr(data-marker-label);
inline-size: 20px;
block-size: 20px;
border: 2px solid #64748b;
border-radius: 50%;
background: #fff;
cursor: pointer;
transition: background-color 0.2s, border-color 0.2s, scale 0.2s;
}
.carousel > .slide::scroll-marker:hover {
border-color: #7c5cff;
background: color-mix(in oklch, #7c5cff 18%, #fff);
}
.carousel > .slide::scroll-marker:focus-visible {
outline: 3px solid color-mix(in oklch, #7c5cff 38%, transparent);
outline-offset: 2px;
}
.carousel > .slide::scroll-marker:target-current {
border-color: #172033;
background: #172033;
box-shadow: 0 0 0 2px #fff, 0 0 0 4px #172033;
scale: 1.1;
}
.carousel::scroll-button(*) {
position: absolute;
position-anchor: --carousel;
z-index: 1;
display: grid;
place-items: center;
inline-size: 2.5rem;
block-size: 2.5rem;
border: 0;
border-radius: 50%;
background: #172033;
color: #fff;
font: inherit;
font-size: 1.15rem;
box-shadow: 0 0.75rem 2rem rgb(0 0 0 / 0.24);
cursor: pointer;
transition: background-color 0.2s, opacity 0.2s, scale 0.2s;
}
.carousel::scroll-button(*):hover {
background: #7c5cff;
scale: 1.08;
}
.carousel::scroll-button(*):focus-visible {
outline: 3px solid color-mix(in oklch, #7c5cff 42%, transparent);
outline-offset: 3px;
}
.carousel::scroll-button(*):disabled {
opacity: 0.35;
cursor: not-allowed;
}
.carousel::scroll-button(inline-start) {
content: "←";
position-area: left center;
translate: 60%;
}
.carousel::scroll-button(inline-end) {
content: "→";
position-area: right center;
translate: -60%;
}
@media (prefers-reduced-motion: reduce) {
.carousel {
scroll-behavior: auto;
}
}.carousel {
display: flex;
flex-direction: row;
gap: 16px;
inline-size: min(100%, 56rem);
margin: 0;
padding: 0;
overflow-x: auto;
overscroll-behavior-inline: contain;
scroll-behavior: smooth;
scroll-snap-type: inline mandatory;
scroll-marker-group: after;
scroll-marker-group: after links;
anchor-name: --carousel;
> .slide {
list-style: none;
flex: 0 0 84%;
scroll-snap-align: center;
scroll-snap-stop: always;
}
&::scroll-marker-group {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding-block: 1.25rem 0.5rem;
}
> .slide::scroll-marker {
content: "" / attr(data-marker-label);
inline-size: 20px;
block-size: 20px;
border: 2px solid #64748b;
border-radius: 50%;
background: #fff;
cursor: pointer;
transition: background-color 0.2s, border-color 0.2s, scale 0.2s;
}
> .slide::scroll-marker:hover {
border-color: #7c5cff;
background: color-mix(in oklch, #7c5cff 18%, #fff);
}
> .slide::scroll-marker:focus-visible {
outline: 3px solid color-mix(in oklch, #7c5cff 38%, transparent);
outline-offset: 2px;
}
> .slide::scroll-marker:target-current {
border-color: #172033;
background: #172033;
box-shadow: 0 0 0 2px #fff, 0 0 0 4px #172033;
scale: 1.1;
}
&::scroll-button(*) {
position: absolute;
position-anchor: --carousel;
z-index: 1;
display: grid;
place-items: center;
inline-size: 2.5rem;
block-size: 2.5rem;
border: 0;
border-radius: 50%;
background: #172033;
color: #fff;
font: inherit;
font-size: 1.15rem;
box-shadow: 0 0.75rem 2rem rgb(0 0 0 / 0.24);
cursor: pointer;
transition: background-color 0.2s, opacity 0.2s, scale 0.2s;
}
&::scroll-button(*):hover {
background: #7c5cff;
scale: 1.08;
}
&::scroll-button(*):focus-visible {
outline: 3px solid color-mix(in oklch, #7c5cff 42%, transparent);
outline-offset: 3px;
}
&::scroll-button(*):disabled {
opacity: 0.35;
cursor: not-allowed;
}
&::scroll-button(inline-start) {
content: "←";
position-area: left center;
translate: 60%;
}
&::scroll-button(inline-end) {
content: "→";
position-area: right center;
translate: -60%;
}
@media (prefers-reduced-motion: reduce) {
scroll-behavior: auto;
}
}
.slide-card {
box-sizing: border-box;
display: grid;
align-content: end;
gap: 0.5rem;
min-block-size: 18rem;
padding: clamp(1.5rem, 5vw, 3rem);
border: 1px solid color-mix(in oklch, var(--slide-accent) 72%, #172033);
border-radius: 1.5rem;
background: var(--slide-accent);
color: #172033;
> * {
margin: 0;
}
h2 {
max-inline-size: 16ch;
font-size: clamp(1.75rem, 6vw, 3.5rem);
line-height: 1;
text-wrap: balance;
}
}
.slide-eyebrow {
color: rgb(23 32 51 / 0.72);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}Educational guide to CSS carousel
A carousel combines HTML content, resting positions, and controls for moving through it. Learn to add native buttons and markers while preserving reading and navigation when those features are unavailable.
Scroll snap handles alignment. A carousel adds an interface to move forward, go back, and choose a destination.
Start with content listed in reading order and a container with overflow: auto. Flexbox arranges the cards; scroll-snap-type and scroll-snap-align define their resting positions. Cards stay in the document even when they are outside the visible area.
Read about the scroll-snap model, axes, and strictnessControls are another layer: the browser can generate buttons on the container and one marker per card. You do not need to duplicate cards or move them with transform. Scrolling, selecting a marker, and moving focus are different actions.
Generating a control, positioning it, and naming it are separate responsibilities.
::scroll-button(inline-start) and ::scroll-button(inline-end) generate buttons when content is not none. In a horizontal LTR writing mode they move backward and forward; a vertical carousel uses block-start and block-end. ::scroll-button(*) shares styles, and :disabled applies automatically at an edge.
A button scrolls by roughly one container page and applies snap rules. It does not always mean advancing by one card: width, gap, and the number of visible cards affect the result. This recipe shows one card per view; check the journey from start to end if you change its size.
scroll-marker-group: after enables the group and places it afterward in box and navigation order. ::scroll-marker-group arranges the controls; each li::scroll-marker generates one with content. before places the group first: match its visual position to its focus order.
::scroll-marker:target-current selects the marker the browser considers current based on scrolling. It is separate from :focus-visible and the URL’s :target. In the example, an underline distinguishes the current destination and a dashed outline distinguishes focus.
Use content: "→" / "Move forward" to separate the symbol from its accessible name. Markers read a visible number and a specific alternative from localized data-* attributes; HTML links show full titles. content: "" without a name does not explain the destination. Check actual announcements: if the browser does not expose the alternative, fallback links keep destinations identifiable.
Choose group position and navigation pattern separately.
Combine before or after with links or tabs: scroll-marker-group: after links and scroll-marker-group: before tabs. before/after position the group; links/tabs change semantics and keyboard behavior. Omitting the mode uses links in an implementation of the current syntax. none disables the group and cannot be combined with these values.
links generates a group with the navigation role and markers with the link role. All markers are Tab stops. Activation directs focus to the target, like a fragment link. Use it to navigate a collection whose contents should remain available in the accessibility tree.
tabs generates tablist, tab and tabpanel roles. Only the active marker is a Tab stop; arrows navigate the markers and activation keeps focus on the tab. Tab continues into the active panel content. The browser hides inactive panels from the accessibility tree: do not choose this mode just for the appearance of the dots.
Chrome announces both modes from Chrome 154. Detect the complete value, not just scroll-marker-group: after. These two recipes keep HTML links as a fallback and only hide them when the explicit mode and ::scroll-marker are accepted. Without support, links and whatever positional controls the browser can generate stay visible; that version does not demonstrate the requested links/tabs pattern. Check keyboard and the accessibility tree in the actual browser.
Write a list in the intended reading order. Content should remain useful without generated controls.
Define scrolling on the container and alignment on each card. Test the first and last cards too.
Add direction buttons and one marker per destination, with names that explain each action.
Write a list in the intended reading order. Content should remain useful without generated controls.
Define scrolling on the container and alignment on each card. Test the first and last cards too.
Add direction buttons and one marker per destination, with names that explain each action.
Compare the same collection with HTML links and with additional CSS controls.
The displayed HTML and CSS provide each result. The base keeps the scrollbar, focus, and links to unique IDs. Links remain visible in the enhanced version too, offering explicit navigation that does not depend on detecting the pseudo-elements.
Write a list in the intended reading order. Content should remain useful without generated controls.
Define scrolling on the container and alignment on each card. Test the first and last cards too.
Add direction buttons and one marker per destination, with names that explain each action.
Write a list in the intended reading order. Content should remain useful without generated controls.
Define scrolling on the container and alignment on each card. Test the first and last cards too.
Add direction buttons and one marker per destination, with names that explain each action.
The recipe places controls underneath using the wrapper’s grid: it does not need anchor positioning. Buttons and the group generate boxes that are siblings of the container according to CSS Overflow 5. If only markers are supported, the group retains its flow layout; each enhancement is checked separately.
CSS reduces controller work, but testing the complete experience remains necessary.
Test Tab and Shift + Tab, activate buttons with Enter or Space, and check every link. Arrow keys may scroll the focused list or move through the native marker group, depending on its mode and the browser. Avoid handlers that intercept those keys on links or fields.
Choose links or tabs according to the content’s purpose and verify support per value. The initial recipes use after; the mode comparisons explicitly add links or tabs. The browser assigns the roles: do not add role="tablist" to HTML to imitate missing support.
Visible position does not mean focus. Keep card names, HTML order, and access to offscreen content. Do not apply aria-hidden or inert to a card just because it is not centered. Verify generated controls’ names, roles, and states with your browser and screen reader combination.
The recipe does not advance automatically. scroll-behavior: smooth improves some scrolling operations and returns to auto with prefers-reduced-motion: reduce. If your product adds automatic rotation, it needs a pause control and must stop when focus or the pointer enters, following the WAI-ARIA carousel pattern.
A grid may suit a short list that should be visible at a glance. A scrolling gallery without extra navigation only needs the snap base. Choose a carousel when moving between destinations is useful and you can keep clear access to all content.
Diagnose each layer before treating a problem as a failure of the whole carousel.
Evaluate carousel parts separately, without extending snap’s status to generated navigation.
Scroll snap is widely available. MDN still classifies ::scroll-button(), ::scroll-marker, scroll-marker-group, and :target-current as experimental features with limited availability as checked on October 13, 2026. Review their tables and each feature’s card before deciding what your product needs.
@supports checks syntax, rather than the visual result, keyboard pattern, or screen reader announcements. The recipe checks buttons and markers separately, keeps links, and does not hide the scrollbar. Ignoring an enhancement should leave useful content and navigation.
If you need custom buttons or an announced state with identical behavior across your target browsers, use native HTML controls and a tested controller. Do not present positive detection or this preview as a guarantee of accessibility or compatibility across engines.