Kosmesis
Components

Carousel

A scroll-snap-based slideshow. Purely presentational — no Morphos equivalent, no Embla dependency.

Installation

npx kosmesis add carousel
pnpm dlx kosmesis add carousel
yarn dlx kosmesis add carousel
bunx kosmesis add carousel

Copy and paste the following code into your project.

carousel.tsx
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/css
pnpm add @praxisjs/css
yarn add @praxisjs/css
bun add @praxisjs/css

Copy and paste the following code into your project.

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

PropTypeDefault
orientation"horizontal" | "vertical""horizontal"
PropTypeDefault
carouselCarouselState— required
orientation"horizontal" | "vertical""horizontal"

CarouselPrevious / CarouselNext

PropTypeDefault
carouselCarouselState— required

children fall back to / glyphs when omitted. CarouselItem takes no props beyond class/children.

On this page