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.
gx add carouselThe 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.
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>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>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"))},
}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"))},
}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;// 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: Default
<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: Vertical
<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
Labelthat names its content. - Give each image an
alttext. - 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. |