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.jsonMit eingetragener Registry (components.json)
npx shadcn@latest add @manniche/cube-carousel