Mikkel Manniche
Dansk English Deutsch

Widgets

Cube carousel

Ein Karussell auf den Seiten eines Würfels, das sich pro Schritt um eine Vierteldrehung dreht, per Ziehen, Tasten oder Knöpfen.

Vorschau

Verwendung

cube-carousel-demo.tsx
import { CubeCarousel } from '@/components/cube-carousel'

const SLIDES = [
  { name: 'Linen shirt', price: '€59', bg: 'from-amber-200 to-orange-300' },
  { name: 'Canvas tote', price: '€24', bg: 'from-lime-200 to-emerald-300' },
  { name: 'Wool scarf', price: '€45', bg: 'from-sky-200 to-indigo-300' },
  { name: 'Leather wallet', price: '€39', bg: 'from-rose-200 to-fuchsia-300' },
  { name: 'Cotton socks', price: '€12', bg: 'from-stone-200 to-stone-400' },
]

export default function CubeCarouselDemo() {
  return (
    <div className="flex justify-center py-4">
      <CubeCarousel
        label="New arrivals"
        slides={SLIDES.map((s) => (
          <div key={s.name} className={`flex size-full flex-col justify-end bg-linear-to-br p-5 text-neutral-900 ${s.bg}`}>
            <p className="text-sm font-medium opacity-70">New in</p>
            <p className="text-2xl font-semibold tracking-tight">{s.name}</p>
            <p className="font-mono">{s.price}</p>
          </div>
        ))}
      />
    </div>
  )
}

Quellcode

cube-carousel.tsx
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { motion, useReducedMotion } from 'motion/react'
import { useState, type ReactNode } from 'react'
import { cn } from '@/lib/utils'

export type CubeCarouselProps = {
  /** One slide per face. Only the four around the cube are used at a time, so any number works. */
  slides: ReactNode[]
  /** Read by screen readers, e.g. “New arrivals”. */
  label: string
  labels?: Partial<Record<'previous' | 'next' | 'slide', string>>
  className?: string
}

const EN = { previous: 'Previous', next: 'Next', slide: 'Slide' }

/**
 * A carousel on the sides of a cube: each step turns it a quarter round.
 * Drag, swipe, the arrow keys or the buttons all turn it.
 */
export function CubeCarousel({ slides, label, labels = {}, className }: CubeCarouselProps) {
  const t = { ...EN, ...labels }
  const [turn, setTurn] = useState(0) // counts quarter turns, may go negative or past the end
  const reduce = useReducedMotion()
  const n = slides.length
  const index = ((turn % n) + n) % n
  const go = (d: number) => setTurn((v) => v + d)

  // The four faces around the current one: which slide each face shows, worked out from the turn count.
  const faces = [0, 1, 2, 3].map((face) => {
    const offset = (((face - turn) % 4) + 4) % 4 // 0 front, 1 right, 2 back, 3 left
    const step = offset === 3 ? -1 : offset
    return { face, slide: (((turn + step) % n) + n) % n, front: offset === 0 }
  })

  return (
    <section
      aria-roledescription="carousel"
      aria-label={label}
      className={cn('w-full max-w-sm', className)}
      onKeyDown={(e) => {
        if (e.key === 'ArrowRight') go(1)
        else if (e.key === 'ArrowLeft') go(-1)
      }}
    >
      {/* The frame is a size container, so the cube's depth (half a side) can be written as 50cqw. */}
      <motion.div
        className="@container relative aspect-[4/3] touch-pan-y [perspective:1100px]"
        onPanEnd={(_, info) => {
          if (Math.abs(info.offset.x) > 40) go(info.offset.x < 0 ? 1 : -1)
        }}
      >
        {/* Pull the cube back by half a side so the front face sits where the frame is. */}
        <div className="absolute inset-0 [transform:translateZ(-50cqw)] [transform-style:preserve-3d]">
          <motion.div
            className="absolute inset-0 [transform-style:preserve-3d]"
            initial={false}
            animate={{ rotateY: -turn * 90 }}
            transition={reduce ? { duration: 0 } : { type: 'spring', bounce: 0, duration: 0.7 }}
          >
            {faces.map(({ face, slide, front }) => (
              <div
                key={face}
                role="group"
                aria-roledescription="slide"
                aria-label={`${t.slide} ${slide + 1} / ${n}`}
                aria-hidden={!front}
                inert={!front}
                className="absolute inset-0 overflow-hidden rounded-2xl border bg-card [backface-visibility:hidden]"
                style={{ transform: `rotateY(${face * 90}deg) translateZ(50cqw)` }}
              >
                {slides[slide]}
              </div>
            ))}
          </motion.div>
        </div>
      </motion.div>

      <div className="mt-3 flex items-center justify-between gap-2">
        <button type="button" onClick={() => go(-1)} aria-label={t.previous} className="grid size-11 place-items-center rounded-full border transition-colors duration-150 hover:bg-muted">
          <ChevronLeft className="size-5" aria-hidden />
        </button>
        <p aria-live="polite" className="text-sm text-muted-foreground tabular-nums">
          {index + 1} / {n}
        </p>
        <button type="button" onClick={() => go(1)} aria-label={t.next} className="grid size-11 place-items-center rounded-full border transition-colors duration-150 hover:bg-muted">
          <ChevronRight className="size-5" aria-hidden />
        </button>
      </div>
    </section>
  )
}

Installieren

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/cube-carousel.json

Mit eingetragener Registry (components.json)

npx shadcn@latest add @manniche/cube-carousel

Dateien

  • cube-carousel.tsx