Display
Carousel
Slides that scroll natively with snap points, with previous, next and a slide picker. Never autoplays.
- Built on
CSS scroll snapIntersectionObserverinertscrollBy- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 3.5 kB
- Built from
- Button
1 of 3
Code
import { Carousel, CarouselSlide } from "@/components/equip/carousel";
const plans = [
{ name: "Starter", price: "Free", detail: "1 project, community support" },
{ name: "Team", price: "$12 per seat", detail: "Unlimited projects, SSO" },
{ name: "Scale", price: "$40 per seat", detail: "Audit log, 99.9% uptime" },
];
export default function Demo() {
return (
<Carousel label="Plans" gap="s" style={{ width: 208 }}>
{plans.map((plan) => (
<CarouselSlide
key={plan.name}
label={plan.name}
style={{
padding: "10px 12px",
border: "1px solid var(--eq-line)",
borderRadius: "var(--eq-radius-m)",
background: "var(--eq-raised)",
}}
>
<div style={{ fontWeight: "var(--eq-weight-strong)" }}>{plan.name}</div>
<div style={{ fontSize: "var(--eq-text-s)" }}>{plan.price}</div>
<div style={{ fontSize: "var(--eq-text-xs)", color: "var(--eq-ink-2)" }}>{plan.detail}</div>
</CarouselSlide>
))}
</Carousel>
);
}Examples
Several per view
Slides more than half out of view are inert, so Tab only reaches the ones a sighted person can see.
1 of 5
Code
import { Carousel, CarouselSlide } from "@/components/equip/carousel";
const regions = [
{ code: "eu-west-1", city: "Dublin", latency: "18 ms" },
{ code: "eu-central-1", city: "Frankfurt", latency: "24 ms" },
{ code: "us-east-1", city: "Virginia", latency: "81 ms" },
{ code: "us-west-2", city: "Oregon", latency: "142 ms" },
{ code: "ap-south-1", city: "Mumbai", latency: "126 ms" },
];
const card = {
display: "grid",
gap: 2,
padding: 12,
border: "1px solid var(--eq-line)",
borderRadius: "var(--eq-radius-m)",
background: "var(--eq-surface)",
} as const;
export default function Example() {
return (
<Carousel label="Regions" slidesPerView={3} style={{ width: "100%", maxWidth: 480 }}>
{regions.map((region) => (
<CarouselSlide key={region.code} label={region.code} style={card}>
<span style={{ fontFamily: "var(--eq-font-mono)", fontVariationSettings: "var(--eq-font-mono-settings)", fontSize: "var(--eq-text-s)" }}>
{region.code}
</span>
<span style={{ color: "var(--eq-ink-2)", fontSize: "var(--eq-text-s)" }}>
{region.city}, {region.latency}
</span>
<a href={`#${region.code}`} style={{ color: "var(--eq-ink)", fontSize: "var(--eq-text-s)" }}>
Select
</a>
</CarouselSlide>
))}
</Carousel>
);
}Loop
With loop, next on the last slide scrolls back to the first instead of disabling. It still never advances on its own.
1 of 4
Code
import { Carousel, CarouselSlide } from "@/components/equip/carousel";
const tips = [
{ label: "Shortcuts", text: "Press G then I to jump to your inbox from anywhere." },
{ label: "Filters", text: "Save a filter to pin it in the sidebar for the whole team." },
{ label: "Exports", text: "Any table exports to CSV from its overflow menu." },
{ label: "Mentions", text: "Mention a team to notify everyone on call this week." },
];
export default function Example() {
return (
<Carousel label="Tips" loop style={{ width: "100%", maxWidth: 320 }}>
{tips.map((tip) => (
<CarouselSlide
key={tip.label}
label={tip.label}
style={{
padding: 16,
border: "1px solid var(--eq-line)",
borderRadius: "var(--eq-radius-m)",
background: "var(--eq-tint)",
}}
>
<strong style={{ display: "block", fontWeight: "var(--eq-weight-strong)" }}>{tip.label}</strong>
<span style={{ color: "var(--eq-ink-2)" }}>{tip.text}</span>
</CarouselSlide>
))}
</Carousel>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
label | string | none | Names the carousel, e.g. "Plans". |
loop | boolean | false | Previous on the first slide goes to the last and next on the last to the first, instead of disabling. |
slidesPerView | number | "auto" | 1 | Slides shown at once. auto keeps each slide's own width. |
gap | "s" | "m" | "l" | "m" | Space between slides. |
...rest | ComponentProps<"section"> | none | Every native section attribute. |
CarouselSlide | label?: string | none | One slide. label names it and its picker dot; without it the slide is named by position, "2 of 5". |
Anatomy
| root | .eq-carousel | A section with aria-roledescription="carousel". |
|---|---|---|
| viewport | .eq-carousel-viewport | The scroll-snap container. Focusable, so arrow keys scroll it. |
| slide | .eq-carousel-slide | inert while less than half of it is in view. |
| controls | .eq-carousel-controls | |
| previous and next | .eq-carousel-nav | Ghost Buttons. |
| picker | .eq-carousel-picker | |
| dot | .eq-carousel-dot | A 24px button with a small mark; the current one stretches. |
| status | .eq-carousel-status | "2 of 5", announced politely. |
Keyboard
| Tab | Moves to the slides, then through the visible slides' content, then previous, the dots and next. |
|---|---|
| ArrowLeftArrowRight | With the slides focused, scrolls natively and snaps to the next slide. |
| EnterSpace | On previous, next or a dot, scrolls to that slide. |
Notes
- It never moves on its own, so there is no pause control to provide: this is the APG carousel without rotation.
- The browser does the scrolling, so touch, trackpad, wheel and keyboard all behave natively; the buttons only call scrollBy.
- Slides mostly out of view are inert, so Tab and screen reader browsing only reach what is visible.
- The dots are plain buttons with aria-current, not tabs: each is a Tab stop, which stays manageable for the handful of slides a carousel should have.
- Previous and next use aria-disabled at the ends instead of disabled, so keyboard focus is not dropped when you reach the last slide.
- CSS scroll buttons and scroll markers could replace the controls in Chrome, but there is one implementation here, the scripted one, so every engine behaves the same.
- Smooth scrolling reads --eq-dur-move and becomes instant when it is 0, which it is under reduced motion.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/carousel.tsx230 lines
"use client";
import {
Children,
createContext,
isValidElement,
useContext,
useEffect,
useId,
useRef,
useState,
type ComponentProps,
type CSSProperties,
type ReactNode,
} from "react";
import { Button } from "./button";
import "./carousel.css";
type SlideState = { index: number; count: number; hidden: boolean | null; id: string };
const SlideContext = createContext<SlideState | null>(null);
export type CarouselProps = Omit<ComponentProps<"section">, "children"> & {
/** Names the carousel, e.g. "Plans". */
label: string;
/** Previous on the first slide goes to the last, and next on the last to the first. */
loop?: boolean;
/** Slides shown at once. "auto" lets each slide keep its own width. */
slidesPerView?: number | "auto";
gap?: "s" | "m" | "l";
children: ReactNode;
};
function Chevron({ back }: { back?: boolean }) {
return (
<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d={back ? "M10 3.5 5.5 8l4.5 4.5" : "M6 3.5 10.5 8 6 12.5"} />
</svg>
);
}
/**
* A row of slides that scrolls natively with scroll snap. Buttons and the
* slide picker only call scrollBy; an IntersectionObserver reports which
* slides are in view. It never moves on its own.
*/
export function Carousel({
label,
loop = false,
slidesPerView = 1,
gap = "m",
className,
style,
children,
...rest
}: CarouselProps) {
const baseId = useId();
const viewportRef = useRef<HTMLDivElement>(null);
const slides = Children.toArray(children).filter(isValidElement);
const count = slides.length;
const [visible, setVisible] = useState<boolean[] | null>(null);
const [current, setCurrent] = useState(0);
const [edges, setEdges] = useState({ start: true, end: count <= 1 });
function readEdges() {
const viewport = viewportRef.current;
if (!viewport) return;
const offset = Math.abs(viewport.scrollLeft);
const max = viewport.scrollWidth - viewport.clientWidth;
const start = offset <= 1;
const end = offset >= max - 1;
setEdges((prev) => (prev.start === start && prev.end === end ? prev : { start, end }));
}
useEffect(() => {
const viewport = viewportRef.current;
if (!viewport) return;
const items = Array.from(viewport.children).filter((el) => el.classList.contains("eq-carousel-slide"));
const ratios = items.map(() => 0);
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const i = items.indexOf(entry.target);
if (i >= 0) ratios[i] = entry.intersectionRatio;
}
const next = ratios.map((r) => r >= 0.5);
const first = next.indexOf(true);
if (first >= 0) setCurrent(first);
setVisible((prev) =>
prev && prev.length === next.length && prev.every((v, i) => v === next[i]) ? prev : next,
);
},
{ root: viewport, threshold: [0, 0.5, 0.99] },
);
for (const item of items) observer.observe(item);
readEdges();
const resize = new ResizeObserver(readEdges);
resize.observe(viewport);
return () => {
observer.disconnect();
resize.disconnect();
};
}, [count]);
function scrollToSlide(index: number) {
const viewport = viewportRef.current;
if (!viewport) return;
const items = Array.from(viewport.children).filter((el) => el.classList.contains("eq-carousel-slide"));
const target = items[index];
if (!target) return;
const rtl = getComputedStyle(viewport).direction === "rtl";
const box = viewport.getBoundingClientRect();
const slide = target.getBoundingClientRect();
const left = rtl ? slide.right - box.right : slide.left - box.left;
const still = getComputedStyle(viewport).getPropertyValue("--eq-dur-move").trim().startsWith("0");
viewport.scrollBy({ left, behavior: still ? "instant" : "smooth" });
}
function step(direction: 1 | -1) {
if (count === 0) return;
const atEdge = direction === 1 ? edges.end : edges.start;
if (atEdge && !loop) return;
if (atEdge) {
scrollToSlide(direction === 1 ? 0 : count - 1);
return;
}
scrollToSlide(Math.min(count - 1, Math.max(0, current + direction)));
}
const perView = slidesPerView === "auto" ? undefined : Math.max(1, slidesPerView);
return (
<section
aria-roledescription="carousel"
aria-label={label}
{...rest}
className={["eq-carousel", className].filter(Boolean).join(" ")}
data-gap={gap}
data-per-view={slidesPerView === "auto" ? "auto" : undefined}
style={perView ? ({ ...style, "--_per": perView } as CSSProperties) : style}
>
<div
ref={viewportRef}
id={`${baseId}-viewport`}
className="eq-carousel-viewport"
tabIndex={0}
aria-label="Slides"
role="group"
onScroll={readEdges}
>
{slides.map((slide, index) => (
<SlideContext.Provider
key={slide.key ?? index}
value={{
index,
count,
hidden: visible ? !visible[index] : null,
id: `${baseId}-slide-${index}`,
}}
>
{slide}
</SlideContext.Provider>
))}
</div>
{count > 1 && (
<div className="eq-carousel-controls">
<Button
variant="ghost"
size="s"
className="eq-carousel-nav"
aria-label="Previous slide"
aria-controls={`${baseId}-viewport`}
aria-disabled={(!loop && edges.start) || undefined}
lead={<Chevron back />}
onClick={() => step(-1)}
/>
<div className="eq-carousel-picker" role="group" aria-label="Choose slide">
{slides.map((slide, index) => {
const name = (slide.props as { label?: unknown }).label;
return (
<button
key={slide.key ?? index}
type="button"
className="eq-carousel-dot"
aria-label={typeof name === "string" ? name : `Slide ${index + 1}`}
aria-controls={`${baseId}-slide-${index}`}
aria-current={index === current || undefined}
onClick={() => scrollToSlide(index)}
/>
);
})}
</div>
<span className="eq-carousel-status" aria-live="polite" aria-atomic="true">
{current + 1} of {count}
</span>
<Button
variant="ghost"
size="s"
className="eq-carousel-nav"
aria-label="Next slide"
aria-controls={`${baseId}-viewport`}
aria-disabled={(!loop && edges.end) || undefined}
lead={<Chevron />}
onClick={() => step(1)}
/>
</div>
)}
</section>
);
}
export type CarouselSlideProps = ComponentProps<"div"> & {
/** Names the slide and its picker dot. Defaults to its position, "2 of 5". */
label?: string;
};
export function CarouselSlide({ label, className, ...rest }: CarouselSlideProps) {
const slide = useContext(SlideContext);
return (
<div
role="group"
aria-roledescription="slide"
aria-label={label ?? (slide ? `${slide.index + 1} of ${slide.count}` : undefined)}
id={slide?.id}
inert={slide?.hidden ?? undefined}
{...rest}
className={["eq-carousel-slide", className].filter(Boolean).join(" ")}
/>
);
}components/equip/carousel.css133 lines
@layer equip {
.eq-carousel {
--_gap: var(--eq-space-3);
--_per: 1;
display: flex;
flex-direction: column;
gap: var(--eq-space-2);
min-width: 0;
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-m) / 1.5 var(--eq-font);
}
.eq-carousel[data-gap="s"] {
--_gap: var(--eq-space-2);
}
.eq-carousel[data-gap="l"] {
--_gap: var(--eq-space-4);
}
.eq-carousel-viewport {
display: flex;
gap: var(--_gap);
overflow-x: auto;
overscroll-behavior-x: contain;
scroll-snap-type: x mandatory;
scrollbar-width: none;
border-radius: var(--eq-radius-m);
}
.eq-carousel-viewport::-webkit-scrollbar {
display: none;
}
.eq-carousel-viewport:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-carousel-slide {
box-sizing: border-box;
flex: 0 0 calc((100% - (var(--_per) - 1) * var(--_gap)) / var(--_per));
min-width: 0;
scroll-snap-align: start;
}
.eq-carousel[data-per-view="auto"] .eq-carousel-slide {
flex: 0 0 auto;
}
.eq-carousel-controls {
display: flex;
align-items: center;
gap: var(--eq-space-1);
}
.eq-carousel-picker {
flex: 1;
display: flex;
flex-wrap: wrap;
justify-content: center;
}
.eq-carousel-dot {
display: grid;
place-items: center;
width: 24px;
height: 24px;
padding: 0;
border: 0;
border-radius: var(--eq-radius-s);
background: transparent;
cursor: pointer;
transition: scale var(--eq-dur) var(--eq-ease);
}
.eq-carousel-dot::before {
content: "";
width: 6px;
height: 6px;
border-radius: var(--eq-radius-full);
background: var(--eq-line-strong);
transition:
width var(--eq-dur-move) var(--eq-spring),
background-color var(--eq-dur) var(--eq-ease);
}
.eq-carousel-dot[aria-current="true"]::before {
width: 16px;
background: var(--eq-solid);
}
@media (hover: hover) {
.eq-carousel-dot:not([aria-current="true"]):hover::before {
background: var(--eq-ink-3);
}
}
.eq-carousel-dot:active {
scale: var(--eq-press);
}
.eq-carousel-dot:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-carousel-status {
min-width: 4.5ch;
color: var(--eq-ink-2);
font-size: var(--eq-text-xs);
font-variant-numeric: tabular-nums;
text-align: center;
white-space: nowrap;
}
/* aria-disabled rather than disabled, so focus stays on the button at the last slide. */
.eq-carousel .eq-carousel-nav[aria-disabled="true"] {
opacity: 0.45;
cursor: not-allowed;
}
.eq-carousel .eq-carousel-nav[aria-disabled="true"]:active {
scale: none;
}
@media (hover: hover) {
.eq-carousel .eq-carousel-controls .eq-carousel-nav[aria-disabled="true"]:hover {
background: transparent;
}
}
}