Skip to content
Gx
GitHub

Carousel

A row of slides that the user moves through one at a time.

<carousel.Carousel id="carousel-default" label="Numbers">
  <carousel.CarouselItem>
    <div class="flex h-40 items-center justify-center rounded-md border border-border bg-card text-4xl font-semibold text-card-foreground">
      1
    </div>
  </carousel.CarouselItem>
  <carousel.CarouselItem>
    <div class="flex h-40 items-center justify-center rounded-md border border-border bg-card text-4xl font-semibold text-card-foreground">
      2
    </div>
  </carousel.CarouselItem>
  <carousel.CarouselItem>
    <div class="flex h-40 items-center justify-center rounded-md border border-border bg-card text-4xl font-semibold text-card-foreground">
      3
    </div>
  </carousel.CarouselItem>
</carousel.Carousel>

Installation

Run the command in the app module.

Terminal
gx add carousel

The command writes these files.

File Path in the app
Carousel.gx ui/carousel/Carousel.gx
CarouselItem.gx ui/carousel/CarouselItem.gx
Carousel.fixtures.go ui/carousel/Carousel.fixtures.go
CarouselItem.fixtures.go ui/carousel/CarouselItem.fixtures.go
CarouselControls.ts ui/carousel/CarouselControls.ts
styles.go ui/carousel/styles.go

Copy each file to its path in the app. Change each import of a registry package to the path of that package in the app.

ui/carousel/Carousel.gx
package carousel

props {
  // Id is the id of the root element.
  Id          string
  // Label is the accessible name of the carousel.
  Label       string = "Carousel"
  // Orientation sets the direction of the slides: Horizontal or Vertical.
  Orientation Orientation = Horizontal
  // Class adds classes to the root element.
  Class       string = ""
  // Children are the slides: one CarouselItem for each.
  Children    gx.Node
  // Attrs adds HTML attributes to the root element.
  Attrs       gx.Attrs = nil
}

<section id={p.Id} data-slot="carousel" aria-roledescription="carousel" aria-label={p.label()} data-orientation={p.orientation()} class={gx.Cx(rootClass, p.Class)} {...p.Attrs}>
  <div id={p.viewportID()} tabindex="0" role="group" aria-label={p.label() + " slides"} class={viewportClass[p.orientation()]}>{p.Children}</div>
  <CarouselControls viewport={p.viewportID()} orientation={string(p.orientation())} previousLabel="Previous slide" nextLabel="Next slide" statusText={statusText} classes={controlClasses} load="eager" />
</section>
ui/carousel/CarouselItem.gx
package carousel

props {
  // Class adds classes to the root element.
  Class    string = ""
  // Children is the content of the slide.
  Children gx.Node
  // Attrs adds HTML attributes to the root element.
  Attrs    gx.Attrs = nil
}

<div role="group" aria-roledescription="slide" data-slot="carousel-item" class={gx.Cx(itemClass, p.Class)} {...p.Attrs}>{p.Children}</div>
ui/carousel/Carousel.fixtures.go
package carousel

import "github.com/alternayte/gx"

// slide returns one demo slide with a number.
func slide(n string) gx.Node {
	return CarouselItem(CarouselItemProps{Children: gx.El("div", gx.Attrs{{Key: "class", Value: "flex h-40 items-center justify-center rounded-md border border-border bg-card text-4xl font-semibold text-card-foreground"}}, gx.Text(n))})
}

var CarouselFixtures = gx.Fixtures[CarouselProps]{
	"Default":  {Id: "carousel-default", Label: "Numbers", Children: gx.Frag(slide("1"), slide("2"), slide("3"))},
	"Vertical": {Id: "carousel-vertical", Label: "Numbers", Orientation: Vertical, Children: gx.Frag(slide("1"), slide("2"), slide("3"))},
}
ui/carousel/CarouselItem.fixtures.go
package carousel

import "github.com/alternayte/gx"

var CarouselItemFixtures = gx.Fixtures[CarouselItemProps]{
	"Default": {Children: gx.El("div", gx.Attrs{{Key: "class", Value: "flex h-40 items-center justify-center rounded-md border border-border bg-card text-4xl font-semibold text-card-foreground"}}, gx.Text("1"))},
}
ui/carousel/CarouselControls.ts
import type { Mount } from "./CarouselControls.props";

// The island adds the previous and next buttons to a carousel. The slides
// are server HTML in a scroll container with CSS scroll snap, so with no
// script a user scrolls them with touch, a wheel or the arrow keys.
const mount: Mount = (root, props, ctx) => {
  const viewport = document.getElementById(props.viewport);
  if (!viewport) throw new Error(`no viewport with the id ${props.viewport}`);
  const c = props.classes;
  const vertical = props.orientation === "vertical";
  const slides = (): HTMLElement[] => [...viewport.children].filter((el): el is HTMLElement => el instanceof HTMLElement);

  root.className = c.root;
  const button = (label: string, text: string): HTMLButtonElement => {
    const b = root.appendChild(document.createElement("button"));
    b.type = "button";
    b.className = c.button;
    b.setAttribute("aria-label", label);
    b.setAttribute("aria-controls", props.viewport);
    b.textContent = text;
    return b;
  };
  const prev = button(props.previousLabel, vertical ? "↑" : "←");
  const status = root.appendChild(document.createElement("div"));
  status.className = c.status;
  status.setAttribute("aria-live", "polite");
  status.setAttribute("aria-atomic", "true");
  const next = button(props.nextLabel, vertical ? "↓" : "→");

  const offset = (el: HTMLElement): number => (vertical ? el.offsetTop - viewport.offsetTop : el.offsetLeft - viewport.offsetLeft);
  const position = (): number => (vertical ? viewport.scrollTop : viewport.scrollLeft);

  // current is the slide whose start is nearest to the scroll position.
  const current = (): number => {
    let best = 0;
    let distance = Infinity;
    slides().forEach((slide, i) => {
      const d = Math.abs(offset(slide) - position());
      if (d < distance) {
        best = i;
        distance = d;
      }
    });
    return best;
  };

  let shown = -1;
  const update = (): void => {
    const all = slides();
    const index = current();
    prev.disabled = index <= 0;
    next.disabled = index >= all.length - 1;
    all.forEach((slide, i) => slide.setAttribute("data-current", String(i === index)));
    if (index !== shown) {
      shown = index;
      status.textContent = props.statusText.replace("{n}", String(index + 1)).replace("{count}", String(all.length));
    }
  };

  const go = (index: number): void => {
    const all = slides();
    const slide = all[Math.max(0, Math.min(index, all.length - 1))];
    if (!slide) return;
    const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
    const to = offset(slide);
    viewport.scrollTo({ [vertical ? "top" : "left"]: to, behavior: reduce ? "auto" : "smooth" });
  };

  const listen = { signal: ctx.abort };
  prev.addEventListener("click", () => go(current() - 1), listen);
  next.addEventListener("click", () => go(current() + 1), listen);
  viewport.addEventListener("scroll", update, { ...listen, passive: true });
  // The arrow keys move one slide; the browser alone scrolls a few pixels.
  viewport.addEventListener(
    "keydown",
    (event) => {
      const back = vertical ? "ArrowUp" : "ArrowLeft";
      const forward = vertical ? "ArrowDown" : "ArrowRight";
      if (event.key !== back && event.key !== forward && event.key !== "Home" && event.key !== "End") return;
      event.preventDefault();
      if (event.key === "Home") go(0);
      else if (event.key === "End") go(slides().length - 1);
      else go(current() + (event.key === forward ? 1 : -1));
    },
    listen,
  );
  update();

  return () => {
    for (const slide of slides()) slide.removeAttribute("data-current");
    root.replaceChildren();
    root.className = "";
  };
};

export default mount;
ui/carousel/styles.go
// Package carousel is a row of slides in a scroll container with CSS scroll
// snap. The CarouselControls island adds the previous and next buttons.
package carousel

import "github.com/alternayte/gx"

// Orientation is the direction of the slides of a carousel.
type Orientation string

// The orientations of carousel.Carousel.
const (
	Horizontal Orientation = "horizontal"
	Vertical   Orientation = "vertical"
)

// CarouselControlsProps are the props of the island that draws the
// buttons. The island scrolls the element with the id Viewport.
type CarouselControlsProps struct {
	// Viewport is the id of the scroll container.
	Viewport string `json:"viewport"`
	// Orientation is "horizontal" or "vertical".
	Orientation string `json:"orientation"`
	// PreviousLabel and NextLabel name the two buttons.
	PreviousLabel string `json:"previousLabel"`
	NextLabel     string `json:"nextLabel"`
	// StatusText is the text of the position, with {n} and {count}.
	StatusText string `json:"statusText"`
	// Classes holds the classes of the parts. They are Go constants, so
	// the stylesheet build sees them.
	Classes ControlClasses `json:"classes"`
}

// ControlClasses are the classes of the parts that the island draws.
type ControlClasses struct {
	Root   string `json:"root"`
	Button string `json:"button"`
	Status string `json:"status"`
}

const rootClass = "flex w-full max-w-sm flex-col gap-2"

// viewportClass is the classes of the scroll container. The slides snap to
// its start.
var viewportClass = gx.Enum[Orientation]{
	Horizontal: "flex snap-x snap-mandatory overflow-x-auto overflow-y-hidden rounded-md outline-none [scrollbar-width:none] focus-visible:ring-[3px] focus-visible:ring-ring/50",
	Vertical:   "flex h-64 snap-y snap-mandatory flex-col overflow-x-hidden overflow-y-auto rounded-md outline-none [scrollbar-width:none] focus-visible:ring-[3px] focus-visible:ring-ring/50",
}

// itemClass is the classes of one slide: as wide and as high as the scroll
// container.
const itemClass = "min-h-0 min-w-0 shrink-0 grow-0 basis-full snap-start"

// statusText is the text of the position. The island fills in the numbers.
const statusText = "Slide {n} of {count}"

var controlClasses = ControlClasses{
	Root:   "flex items-center justify-center gap-3",
	Button: "inline-flex size-8 items-center justify-center rounded-full border border-border bg-background text-sm shadow-xs outline-none hover:bg-accent hover:text-accent-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50",
	Status: "min-w-24 text-center text-sm text-muted-foreground",
}

// label returns the accessible name; a zero value is "Carousel".
func (p CarouselProps) label() string {
	if p.Label == "" {
		return "Carousel"
	}
	return p.Label
}

// orientation returns the direction; a zero value is Horizontal.
func (p CarouselProps) orientation() Orientation {
	if p.Orientation == Vertical {
		return Vertical
	}
	return Horizontal
}

func (p CarouselProps) viewportID() string { return p.Id + "-viewport" }

The theme must define these tokens: --border, --background, --accent, --accent-foreground, --ring, --muted-foreground.

Usage

<carousel.Carousel id="photos" label="Photos">
  <carousel.CarouselItem><img src="/one.jpg" alt="The harbour" /></carousel.CarouselItem>
  <carousel.CarouselItem><img src="/two.jpg" alt="The old town" /></carousel.CarouselItem>
</carousel.Carousel>

The slides are server HTML in a scroll container with CSS scroll snap. The island adds the previous and next buttons and tells a screen reader the position. With no script the user scrolls the slides with touch, a wheel or the arrow keys.

Examples

<carousel.Carousel id="carousel-default" label="Numbers">
  <carousel.CarouselItem>
    <div class="flex h-40 items-center justify-center rounded-md border border-border bg-card text-4xl font-semibold text-card-foreground">
      1
    </div>
  </carousel.CarouselItem>
  <carousel.CarouselItem>
    <div class="flex h-40 items-center justify-center rounded-md border border-border bg-card text-4xl font-semibold text-card-foreground">
      2
    </div>
  </carousel.CarouselItem>
  <carousel.CarouselItem>
    <div class="flex h-40 items-center justify-center rounded-md border border-border bg-card text-4xl font-semibold text-card-foreground">
      3
    </div>
  </carousel.CarouselItem>
</carousel.Carousel>
<carousel.Carousel id="carousel-vertical" label="Numbers" orientation={carousel.Vertical}>
  <carousel.CarouselItem>
    <div class="flex h-40 items-center justify-center rounded-md border border-border bg-card text-4xl font-semibold text-card-foreground">
      1
    </div>
  </carousel.CarouselItem>
  <carousel.CarouselItem>
    <div class="flex h-40 items-center justify-center rounded-md border border-border bg-card text-4xl font-semibold text-card-foreground">
      2
    </div>
  </carousel.CarouselItem>
  <carousel.CarouselItem>
    <div class="flex h-40 items-center justify-center rounded-md border border-border bg-card text-4xl font-semibold text-card-foreground">
      3
    </div>
  </carousel.CarouselItem>
</carousel.Carousel>

CarouselItem: Default

<carousel.CarouselItem>
  <div class="flex h-40 items-center justify-center rounded-md border border-border bg-card text-4xl font-semibold text-card-foreground">
    1
  </div>
</carousel.CarouselItem>

API reference

A tag sets a prop by its name with a lower-case first letter: Class is class.

carousel.Carousel

Prop Type Default Description
Id string Required Id is the id of the root element.
Label string "Carousel" Label is the accessible name of the carousel.
Orientation Orientation Horizontal Orientation sets the direction of the slides: Horizontal or Vertical.
Class string "" Class adds classes to the root element.
Children gx.Node Required Children are the slides: one CarouselItem for each.
Attrs gx.Attrs nil Attrs adds HTML attributes to the root element.

carousel.CarouselItem

Prop Type Default Description
Class string "" Class adds classes to the root element.
Children gx.Node Required Children is the content of the slide.
Attrs gx.Attrs nil Attrs adds HTML attributes to the root element.

Do and do not

Do

  • Give the carousel a Label that names its content.
  • Give each image an alt text.
  • Keep the count of slides small. Each slide loads with the page.

Don't

  • Do not move the slides on a timer.
  • Do not put the only copy of important content on a later slide.

Keyboard

Key Action
Tab Moves focus to the slides, then to the previous and next buttons.
Arrow Left, Arrow Right Moves to the previous or next slide. A vertical carousel uses Arrow Up and Arrow Down.
Home, End Moves to the first or last slide.
Enter, Space Runs the button that has the focus.