Mikkel Manniche
Dansk English Deutsch

Widgets

Cube carousel

En karrusel på siderne af en terning, der drejer en kvart omgang pr. skridt, med træk, taster eller knapper.

Forhåndsvisning

Brug

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>
  )
}

Kilde

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>
  )
}

Installér

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

Med registret tilføjet (components.json)

npx shadcn@latest add @manniche/cube-carousel

Filer

  • cube-carousel.tsx