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
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.

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.

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

PropTypeDefaultWhat it does
labelstringnoneNames the carousel, e.g. "Plans".
loopbooleanfalsePrevious on the first slide goes to the last and next on the last to the first, instead of disabling.
slidesPerViewnumber | "auto"1Slides shown at once. auto keeps each slide's own width.
gap"s" | "m" | "l""m"Space between slides.
...restComponentProps<"section">noneEvery native section attribute.
CarouselSlidelabel?: stringnoneOne slide. label names it and its picker dot; without it the slide is named by position, "2 of 5".

Anatomy

root.eq-carouselA section with aria-roledescription="carousel".
viewport.eq-carousel-viewportThe scroll-snap container. Focusable, so arrow keys scroll it.
slide.eq-carousel-slideinert while less than half of it is in view.
controls.eq-carousel-controls
previous and next.eq-carousel-navGhost Buttons.
picker.eq-carousel-picker
dot.eq-carousel-dotA 24px button with a small mark; the current one stretches.
status.eq-carousel-status"2 of 5", announced politely.

Keyboard

TabMoves to the slides, then through the visible slides' content, then previous, the dots and next.
ArrowLeftArrowRightWith the slides focused, scrolls natively and snaps to the next slide.
EnterSpaceOn 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;
    }
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index