Agents (llms.txt)

Carousel

Horizontal carousel of slides — snap (single), multi (multiple visible), or marquee (auto-scroll).

Featured work

A few projects we shipped this year.

Discover
Design
Develop
Deliver
import { Carousel } from '@/foundations/blocks/carousel';

const slides = [
  { tone: 'bg-info/15 text-info', label: 'Discover' },
  { tone: 'bg-success/15 text-success', label: 'Design' },
  { tone: 'bg-warning/15 text-warning', label: 'Develop' },
  { tone: 'bg-error/15 text-error', label: 'Deliver' },
];

const Slide = ({ tone, label }: { tone: string; label: string }) => (
  <div
    className={`grid aspect-[16/9] w-full place-items-center font-semibold text-2xl ${tone}`}
  >
    {label}
  </div>
);

export default function CarouselPreview() {
  return (
    <Carousel
      title="Featured work"
      description="A few projects we shipped this year."
      slides={slides.map((s) => (
        <Slide key={s.label} tone={s.tone} label={s.label} />
      ))}
    />
  );
}

export const meta = {
  layout: 'fullscreen',
};

Dependencies

Source Code

'use client';

import { ChevronLeft, ChevronRight } from '@untitledui-pro/icons/solid';
import { useCallback, useEffect, useRef, useState } from 'react';

import { Container } from '@/components/container';
import { cn } from '@/lib/utils/classnames';

interface CarouselProps
  extends Omit<React.ComponentPropsWithRef<'section'>, 'title'> {
  title?: React.ReactNode;
  description?: React.ReactNode;
  /** Slides — image elements, video elements, or any node. */
  slides: React.ReactNode[];
  /**
   * Layout mode:
   * - `snap` (default) — one slide at a time, native scroll-snap, arrows + dots
   * - `multi` — multiple slides visible at once, arrows
   * - `marquee` — auto-scrolling seamless strip, pauses on hover
   */
  variant?: 'snap' | 'multi' | 'marquee';
  /** For `multi`: number of slides visible at desktop. Default 3. */
  visible?: number;
  /** For `marquee`: pixels per second. Default 40. */
  speed?: number;
  /** Required for screen readers. */
  label?: string;
}

const Carousel = ({
  ref,
  className,
  title,
  description,
  slides,
  variant = 'snap',
  visible = 3,
  speed = 40,
  label = 'Carousel',
  ...rest
}: CarouselProps) => {
  const scrollerRef = useRef<HTMLDivElement | null>(null);
  const [activeIndex, setActiveIndex] = useState(0);

  // Track which slide is most visible (snap + multi only).
  useEffect(() => {
    if (variant === 'marquee') return;
    const el = scrollerRef.current;
    if (!el) return;

    const items = Array.from(
      el.querySelectorAll<HTMLElement>('[data-carousel-item]')
    );
    if (items.length === 0) return;

    const observer = new IntersectionObserver(
      (entries) => {
        const best = entries
          .filter((e) => e.isIntersecting)
          .sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
        if (!best) return;
        const idx = items.indexOf(best.target as HTMLElement);
        if (idx >= 0) setActiveIndex(idx);
      },
      { root: el, threshold: [0.5, 0.75, 1] }
    );

    for (const item of items) observer.observe(item);
    return () => observer.disconnect();
  }, [variant]);

  const scrollToIndex = useCallback((idx: number) => {
    const el = scrollerRef.current;
    if (!el) return;
    const items = el.querySelectorAll<HTMLElement>('[data-carousel-item]');
    const target = items[idx];
    if (!target) return;
    el.scrollTo({
      left: target.offsetLeft - el.offsetLeft,
      behavior: 'smooth',
    });
  }, []);

  const scrollBy = useCallback((dir: 1 | -1) => {
    const el = scrollerRef.current;
    if (!el) return;
    const items = el.querySelectorAll<HTMLElement>('[data-carousel-item]');
    if (items.length === 0) return;
    const step = (items[0] as HTMLElement).offsetWidth + 16; // gap-4
    el.scrollBy({ left: step * dir, behavior: 'smooth' });
  }, []);

  const showArrows = variant !== 'marquee';
  const showDots = variant === 'snap';

  return (
    <section
      ref={ref}
      className={cn('w-full py-16 md:py-24', className)}
      aria-roledescription="carousel"
      aria-label={label}
      {...rest}
    >
      <Container>
        {(title || description || showArrows) && (
          <div className="mb-8 flex items-end justify-between gap-4 md:mb-12">
            <div className="flex flex-col gap-2">
              {title && (
                <h2 className="text-balance text-3xl md:text-4xl lg:text-5xl">
                  {title}
                </h2>
              )}
              {description && (
                <p className="max-w-xl text-pretty text-foreground-secondary text-lg md:text-xl">
                  {description}
                </p>
              )}
            </div>

            {showArrows && (
              <div className="flex shrink-0 gap-2">
                <button
                  type="button"
                  onClick={() => scrollBy(-1)}
                  aria-label="Previous slide"
                  className={cn(
                    'grid size-10 place-items-center rounded-full border border-border bg-background text-foreground',
                    'transition-colors duration-(--duration-hover) ease-out',
                    'hover:bg-foreground/5',
                    'disabled:opacity-40'
                  )}
                  disabled={activeIndex === 0}
                >
                  <ChevronLeft className="size-4" />
                </button>
                <button
                  type="button"
                  onClick={() => scrollBy(1)}
                  aria-label="Next slide"
                  className={cn(
                    'grid size-10 place-items-center rounded-full border border-border bg-background text-foreground',
                    'transition-colors duration-(--duration-hover) ease-out',
                    'hover:bg-foreground/5',
                    'disabled:opacity-40'
                  )}
                  disabled={activeIndex >= slides.length - 1}
                >
                  <ChevronRight className="size-4" />
                </button>
              </div>
            )}
          </div>
        )}
      </Container>

      {variant === 'marquee' ? (
        <div
          className="group relative overflow-hidden"
          // Pause on hover via CSS variable swap.
          style={
            {
              '--marquee-duration': `${(slides.length * 320) / speed}s`,
            } as React.CSSProperties
          }
        >
          <div
            className={cn(
              'flex w-max gap-6 motion-reduce:animate-none',
              'animate-[carousel-marquee_var(--marquee-duration)_linear_infinite]',
              'group-hover:[animation-play-state:paused]'
            )}
          >
            {[...slides, ...slides].map((slide, i) => (
              <div
                key={i}
                className="w-72 shrink-0 overflow-hidden rounded-xl bg-surface-sunken md:w-96"
              >
                {slide}
              </div>
            ))}
          </div>

          <style>
            {`@keyframes carousel-marquee {
              from { transform: translateX(0); }
              to { transform: translateX(-50%); }
            }`}
          </style>
        </div>
      ) : (
        <Container>
          <div
            ref={scrollerRef}
            className={cn(
              'flex snap-x snap-mandatory gap-4 overflow-x-auto pb-4',
              // Hide scrollbar — leave room for the dots / arrows.
              '[scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
            )}
            style={
              {
                '--carousel-visible': variant === 'multi' ? visible : 1,
              } as React.CSSProperties
            }
          >
            {slides.map((slide, i) => (
              <div
                key={i}
                data-carousel-item
                className={cn(
                  'shrink-0 snap-start overflow-hidden rounded-xl bg-surface-sunken',
                  variant === 'snap' && 'w-full',
                  variant === 'multi' &&
                    'w-full md:w-[calc((100%-(var(--carousel-visible)-1)*1rem)/var(--carousel-visible))]'
                )}
              >
                {slide}
              </div>
            ))}
          </div>
        </Container>
      )}

      {showDots && (
        <Container>
          <div className="mt-6 flex justify-center gap-2">
            {slides.map((_, i) => (
              <button
                key={i}
                type="button"
                onClick={() => scrollToIndex(i)}
                aria-label={`Go to slide ${i + 1}`}
                aria-current={i === activeIndex || undefined}
                className={cn(
                  'h-1.5 rounded-full transition-all duration-(--duration-hover) ease-out',
                  i === activeIndex
                    ? 'w-6 bg-foreground'
                    : 'w-1.5 bg-foreground/20 hover:bg-foreground/40'
                )}
              />
            ))}
          </div>
        </Container>
      )}
    </section>
  );
};

export type { CarouselProps };
export { Carousel };

Carousel is a horizontal strip of slides driven by native scroll-snap. Three variants cover the common marketing use cases: a one-up snap carousel with prev/next + dot indicators, a multi-up grid carousel, and an auto-scrolling marquee that pauses on hover.

Anatomy


          <Carousel
  title="Featured work"
  description="A few projects we shipped this year."
  slides={[<img src="..." />, <img src="..." />, <img src="..." />]}
/>
        

Variants

  • snap (default) — one slide at a time with prev/next arrows and dot indicators.
  • multi — multiple slides visible at once (set visible to control). Arrows only.
  • marquee — auto-scrolling seamless strip. Pauses on hover. No arrows or dots.

The marquee variant respects prefers-reduced-motion.

API Reference

Extends the section element.

Prop Default Type
title - ReactNode
description - ReactNode
slides * - ReactNode[]
variant 'snap' 'snap''multi''marquee'
visible 3 number
speed 40 number
label 'Carousel' string

Examples

Default — snap

Featured work

A few projects we shipped this year.

Discover
Design
Develop
Deliver
import { Carousel } from '@/foundations/blocks/carousel';

const slides = [
  { tone: 'bg-info/15 text-info', label: 'Discover' },
  { tone: 'bg-success/15 text-success', label: 'Design' },
  { tone: 'bg-warning/15 text-warning', label: 'Develop' },
  { tone: 'bg-error/15 text-error', label: 'Deliver' },
];

const Slide = ({ tone, label }: { tone: string; label: string }) => (
  <div
    className={`grid aspect-[16/9] w-full place-items-center font-semibold text-2xl ${tone}`}
  >
    {label}
  </div>
);

export default function CarouselPreview() {
  return (
    <Carousel
      title="Featured work"
      description="A few projects we shipped this year."
      slides={slides.map((s) => (
        <Slide key={s.label} tone={s.tone} label={s.label} />
      ))}
    />
  );
}

export const meta = {
  layout: 'fullscreen',
};

Multi — three visible at once

Recent case studies

Three at a time on desktop, scroll the rest.

import { Carousel } from '@/foundations/blocks/carousel';

const items = [
  { tone: 'bg-info/15 text-info', name: 'Acme' },
  { tone: 'bg-success/15 text-success', name: 'Initech' },
  { tone: 'bg-warning/15 text-warning', name: 'Globex' },
  { tone: 'bg-error/15 text-error', name: 'Soylent' },
  { tone: 'bg-accent/15 text-accent', name: 'Umbrella' },
  { tone: 'bg-foreground/10 text-foreground', name: 'Hooli' },
];

const Card = ({ tone, name }: { tone: string; name: string }) => (
  <div className={`flex aspect-[4/5] w-full flex-col justify-end p-6 ${tone}`}>
    <p className="font-semibold text-xl">{name}</p>
    <p className="text-current/70 text-sm">Case study</p>
  </div>
);

export default function CarouselMultiPreview() {
  return (
    <Carousel
      variant="multi"
      visible={3}
      title="Recent case studies"
      description="Three at a time on desktop, scroll the rest."
      slides={items.map((s) => (
        <Card key={s.name} tone={s.tone} name={s.name} />
      ))}
    />
  );
}

export const meta = {
  layout: 'fullscreen',
};

Marquee — auto-scrolling

Principles

Auto-scrolling strip. Hover to pause.

Pragmatic

Honest

Quiet

Composable

Owned

Documented

Tested

Shipped

Pragmatic

Honest

Quiet

Composable

Owned

Documented

Tested

Shipped

import { Carousel } from '@/foundations/blocks/carousel';

const items = [
  'Pragmatic',
  'Honest',
  'Quiet',
  'Composable',
  'Owned',
  'Documented',
  'Tested',
  'Shipped',
];

const Tile = ({ word }: { word: string }) => (
  <div className="flex aspect-16/10 items-center justify-center bg-surface-default p-6">
    <p className="font-semibold text-3xl">{word}</p>
  </div>
);

export default function CarouselMarqueePreview() {
  return (
    <Carousel
      variant="marquee"
      title="Principles"
      description="Auto-scrolling strip. Hover to pause."
      slides={items.map((w) => <Tile key={w} word={w} />)}
    />
  );
}

export const meta = {
  layout: 'fullscreen',
};

Previous

About

Next

Core Values