Components
Carousel
A scroll-snap-based slideshow. Purely presentational — no Morphos equivalent, no Embla dependency.
Installation
npx kosmesis add carouselpnpm dlx kosmesis add carouselyarn dlx kosmesis add carouselbunx kosmesis add carouselCopy and paste the following code into your project.
import { StatefulComponent, StatelessComponent } from "@praxisjs/core";import { Component, Prop, Ref, State, type Ref as RefType } from "@praxisjs/decorators";import type { Children } from "@praxisjs/shared";import { cn } from "@/lib/utils";export interface CarouselProps { orientation?: "horizontal" | "vertical"; class?: string; children?: Children;}/** * Purely presentational + scroll-snap — no Morphos equivalent, and deliberately avoids a heavy * dependency like Embla: native CSS scroll-snap plus `scrollBy` handles the sliding, dragging, * and snapping for free. `CarouselState` owns just enough to know whether prev/next are * available and to expose `.scrollPrev()`/`.scrollNext()` to `CarouselPrevious`/`CarouselNext`. */@Component()export class CarouselState extends StatefulComponent { @Prop() orientation: CarouselProps["orientation"] = "horizontal"; @State() canScrollPrev = false; @State() canScrollNext = true; @Ref<HTMLDivElement>() viewportRef!: RefType<HTMLDivElement>; updateScrollState(): void { const el = this.viewportRef.current; if (!el) return; if (this.orientation === "horizontal") { this.canScrollPrev = el.scrollLeft > 0; this.canScrollNext = el.scrollLeft + el.clientWidth < el.scrollWidth - 1; } else { this.canScrollPrev = el.scrollTop > 0; this.canScrollNext = el.scrollTop + el.clientHeight < el.scrollHeight - 1; } } scrollPrev(): void { const el = this.viewportRef.current; if (!el) return; const amount = this.orientation === "horizontal" ? -el.clientWidth : -el.clientHeight; el.scrollBy(this.orientation === "horizontal" ? { left: amount, behavior: "smooth" } : { top: amount, behavior: "smooth" }); } scrollNext(): void { const el = this.viewportRef.current; if (!el) return; const amount = this.orientation === "horizontal" ? el.clientWidth : el.clientHeight; el.scrollBy(this.orientation === "horizontal" ? { left: amount, behavior: "smooth" } : { top: amount, behavior: "smooth" }); } /** Pure state container — never mounted via JSX, only instantiated directly. */ render() { return null; }}export interface CarouselComposedProps { carousel: CarouselState; orientation?: "horizontal" | "vertical"; class?: string; children?: Children;}@Component()export class Carousel extends StatelessComponent<CarouselComposedProps> { render() { const { carousel, orientation = "horizontal", class: cls, children } = this.props; return ( <div role="region" aria-roledescription="carousel" class={cn("relative", cls)} onKeyDown={(event: KeyboardEvent) => { if (event.key === "ArrowLeft") { event.preventDefault(); carousel.scrollPrev(); } else if (event.key === "ArrowRight") { event.preventDefault(); carousel.scrollNext(); } }} > <div ref={carousel.viewportRef} class={cn( "flex overflow-x-auto scroll-smooth [scrollbar-width:none]", orientation === "vertical" && "flex-col overflow-x-hidden overflow-y-auto", )} style={{ scrollSnapType: orientation === "horizontal" ? "x mandatory" : "y mandatory" }} onScroll={() => { carousel.updateScrollState(); }} > {children} </div> </div> ); }}export interface CarouselItemProps { class?: string; children?: Children;}@Component()export class CarouselItem extends StatelessComponent<CarouselItemProps> { render() { const { class: cls, children } = this.props; return ( <div role="group" aria-roledescription="slide" class={cn("min-w-0 shrink-0 grow-0 basis-full", cls)} style={{ scrollSnapAlign: "start" }} > {children} </div> ); }}export interface CarouselControlProps { carousel: CarouselState; class?: string; children?: Children;}@Component()export class CarouselPrevious extends StatelessComponent<CarouselControlProps> { render() { const { carousel, class: cls, children } = this.props; return ( <button type="button" aria-label="Previous slide" disabled={() => !carousel.canScrollPrev} class={cn( "absolute top-1/2 left-2 size-8 -translate-y-1/2 rounded-full border bg-background shadow-xs disabled:pointer-events-none disabled:opacity-50", cls, )} onClick={() => { carousel.scrollPrev(); }} > {children ?? "‹"} </button> ); }}@Component()export class CarouselNext extends StatelessComponent<CarouselControlProps> { render() { const { carousel, class: cls, children } = this.props; return ( <button type="button" aria-label="Next slide" disabled={() => !carousel.canScrollNext} class={cn( "absolute top-1/2 right-2 size-8 -translate-y-1/2 rounded-full border bg-background shadow-xs disabled:pointer-events-none disabled:opacity-50", cls, )} onClick={() => { carousel.scrollNext(); }} > {children ?? "›"} </button> ); }}Install the following dependencies:
npm install @praxisjs/csspnpm add @praxisjs/cssyarn add @praxisjs/cssbun add @praxisjs/cssCopy and paste the following code into your project.
import { StatefulComponent, StatelessComponent } from "@praxisjs/core";import { cx, Stylesheet, Styled, tokenVars } from "@praxisjs/css";import { Component, Prop, Ref, State, type Ref as RefType } from "@praxisjs/decorators";import type { Children } from "@praxisjs/shared";import { KosmesisTokens } from "@/lib/kosmesis-theme";const t = tokenVars(KosmesisTokens);export interface CarouselProps { orientation?: "horizontal" | "vertical"; class?: string; children?: Children;}/** * Purely presentational + scroll-snap — no Morphos equivalent, and deliberately avoids a heavy * dependency like Embla: native CSS scroll-snap plus `scrollBy` handles the sliding, dragging, * and snapping for free. `CarouselState` owns just enough to know whether prev/next are * available and to expose `.scrollPrev()`/`.scrollNext()` to `CarouselPrevious`/`CarouselNext`. */@Component()export class CarouselState extends StatefulComponent { @Prop() orientation: CarouselProps["orientation"] = "horizontal"; @State() canScrollPrev = false; @State() canScrollNext = true; @Ref<HTMLDivElement>() viewportRef!: RefType<HTMLDivElement>; updateScrollState(): void { const el = this.viewportRef.current; if (!el) return; if (this.orientation === "horizontal") { this.canScrollPrev = el.scrollLeft > 0; this.canScrollNext = el.scrollLeft + el.clientWidth < el.scrollWidth - 1; } else { this.canScrollPrev = el.scrollTop > 0; this.canScrollNext = el.scrollTop + el.clientHeight < el.scrollHeight - 1; } } scrollPrev(): void { const el = this.viewportRef.current; if (!el) return; const amount = this.orientation === "horizontal" ? -el.clientWidth : -el.clientHeight; el.scrollBy(this.orientation === "horizontal" ? { left: amount, behavior: "smooth" } : { top: amount, behavior: "smooth" }); } scrollNext(): void { const el = this.viewportRef.current; if (!el) return; const amount = this.orientation === "horizontal" ? el.clientWidth : el.clientHeight; el.scrollBy(this.orientation === "horizontal" ? { left: amount, behavior: "smooth" } : { top: amount, behavior: "smooth" }); } /** Pure state container — never mounted via JSX, only instantiated directly. */ render() { return null; }}class CarouselStyles extends Stylesheet { $root = this.css({ position: "relative" }); $viewport = this.css({ display: "flex", overflowX: "auto", scrollBehavior: "smooth", scrollbarWidth: "none" }); $viewportVertical = this.css({ flexDirection: "column", overflowX: "hidden", overflowY: "auto" }); $item = this.css({ minWidth: "0", flexShrink: 0, flexGrow: 0, flexBasis: "100%" }); $control = this.css({ position: "absolute", top: "50%", width: "2rem", height: "2rem", transform: "translateY(-50%)", borderRadius: "9999px", border: `1px solid ${t.border}`, backgroundColor: t.background, boxShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)", }).disabled({ pointerEvents: "none", opacity: 0.5 }); $prev = this.css({ left: "0.5rem" }); $next = this.css({ right: "0.5rem" });}export interface CarouselComposedProps { carousel: CarouselState; orientation?: "horizontal" | "vertical"; class?: string; children?: Children;}@Component()export class Carousel extends StatelessComponent<CarouselComposedProps> { @Styled(CarouselStyles) $s!: CarouselStyles; render() { const { carousel, orientation = "horizontal", class: cls, children } = this.props; return ( <div role="region" aria-roledescription="carousel" class={cx(this.$s.$root, cls)} onKeyDown={(event: KeyboardEvent) => { if (event.key === "ArrowLeft") { event.preventDefault(); carousel.scrollPrev(); } else if (event.key === "ArrowRight") { event.preventDefault(); carousel.scrollNext(); } }} > <div ref={carousel.viewportRef} class={cx(this.$s.$viewport, orientation === "vertical" && this.$s.$viewportVertical)} style={{ scrollSnapType: orientation === "horizontal" ? "x mandatory" : "y mandatory" }} onScroll={() => { carousel.updateScrollState(); }} > {children} </div> </div> ); }}export interface CarouselItemProps { class?: string; children?: Children;}@Component()export class CarouselItem extends StatelessComponent<CarouselItemProps> { @Styled(CarouselStyles) $s!: CarouselStyles; render() { const { class: cls, children } = this.props; return ( <div role="group" aria-roledescription="slide" class={cx(this.$s.$item, cls)} style={{ scrollSnapAlign: "start" }}> {children} </div> ); }}export interface CarouselControlProps { carousel: CarouselState; class?: string; children?: Children;}@Component()export class CarouselPrevious extends StatelessComponent<CarouselControlProps> { @Styled(CarouselStyles) $s!: CarouselStyles; render() { const { carousel, class: cls, children } = this.props; return ( <button type="button" aria-label="Previous slide" disabled={() => !carousel.canScrollPrev} class={cx(this.$s.$control, this.$s.$prev, cls)} onClick={() => { carousel.scrollPrev(); }} > {children ?? "‹"} </button> ); }}@Component()export class CarouselNext extends StatelessComponent<CarouselControlProps> { @Styled(CarouselStyles) $s!: CarouselStyles; render() { const { carousel, class: cls, children } = this.props; return ( <button type="button" aria-label="Next slide" disabled={() => !carousel.canScrollNext} class={cx(this.$s.$control, this.$s.$next, cls)} onClick={() => { carousel.scrollNext(); }} > {children ?? "›"} </button> ); }}Examples
Usage
import { Carousel, CarouselItem, CarouselNext, CarouselPrevious, CarouselState } from "@/components/ui/carousel";
@Component()
class PhotoCarousel extends StatefulComponent {
@State() carousel = new CarouselState();
render() {
return (
<Carousel carousel={this.carousel} class="w-full max-w-xs">
<CarouselItem><img src="/photo-1.jpg" alt="" /></CarouselItem>
<CarouselItem><img src="/photo-2.jpg" alt="" /></CarouselItem>
<CarouselItem><img src="/photo-3.jpg" alt="" /></CarouselItem>
<CarouselPrevious carousel={this.carousel} />
<CarouselNext carousel={this.carousel} />
</Carousel>
);
}
}Deliberately avoids a heavy dependency like Embla: native CSS scroll-snap plus scrollBy
handles sliding, dragging (via native touch/trackpad scroll), and snapping for free.
CarouselState just tracks whether prev/next are available.
Props
CarouselState
Instantiate directly (@State() carousel = new CarouselState()) and pass it to Carousel and
the control parts below.
| Prop | Type | Default |
|---|---|---|
orientation | "horizontal" | "vertical" | "horizontal" |
Carousel
| Prop | Type | Default |
|---|---|---|
carousel | CarouselState | — required |
orientation | "horizontal" | "vertical" | "horizontal" |
CarouselPrevious / CarouselNext
| Prop | Type | Default |
|---|---|---|
carousel | CarouselState | — required |
children fall back to ‹/› glyphs when omitted. CarouselItem takes no props beyond
class/children.