Carousel
Portado desde el carrusel coverflow de Vértice — la interacción (índice activo, avance circular, clic para saltar, auto-play que pausa al hover) es la misma en las tres variantes; lo que cambia es la piel. El glow cian/magenta de Vértice era una excepción de esa marca, acá vuelve al lenguaje plano de Kenza: hairline, las mismas dos marcas de esquina que ya usa Card interactive, sin degradados ni sombra.
Playground
<Carousel variant="facet" items={items} />Variantes
variant cambia la forma y el gesto de "esto está activo" — plate ni siquiera comparte el mecanismo de transform de las otras dos.
facet
Corte de esquina angular — la misma geometría del símbolo. Desliza en línea recta.




Alba
Spa & bienestar
plate
Una pieza expuesta a la vez con ficha de catálogo. No desliza: crossfade + miniaturas.
orbit
Como facet, pero cada pieza gira sobre su eje (rotateY) al alejarse del centro.
Código
"use client";
import * as React from "react";
import { cn } from "./lib/cn";
import { EASE } from "./lib/motion";
export interface CarouselItem {
title: string;
description?: string;
image: string;
/** etiqueta corta opcional (p. ej. "01") — mono, dorado */
index?: string;
}
export interface CarouselProps {
items: CarouselItem[];
/**
* facet — corte de esquina angular + las mismas dos marcas de esquina
* que ya usa `Card interactive`, encendidas solo en la pieza activa.
* plate — una pieza "expuesta" a la vez con ficha de catálogo, tira
* de miniaturas abajo (no es coverflow: no hay transform de posición).
* orbit — como facet pero las piezas describen un arco (rotateY +
* curva vertical) en vez de deslizar en línea recta.
*/
variant?: "facet" | "plate" | "orbit";
className?: string;
/** milisegundos de auto-avance en facet/orbit — 0 desactiva. plate nunca avanza sola. */
autoPlayMs?: number;
}
function circularDelta(i: number, active: number, count: number) {
let d = i - active;
if (d > count / 2) d -= count;
if (d < -count / 2) d += count;
return d;
}
// dos esquinas opuestas que se cierran como un visor — el mismo gesto
// que ya usa `Card interactive`, no uno nuevo (ver card.tsx). Acá se
// dispara con `data-active`, no con :hover, porque la pieza que se
// "ilumina" es la del centro, no la que está bajo el cursor.
const cornerBase = cn(
"pointer-events-none absolute h-3 w-3 border-primary opacity-0 transition-[opacity,transform] duration-300",
EASE,
);
function ActiveCorners({ active }: { active: boolean }) {
return (
<>
<span
aria-hidden="true"
className={cn(
cornerBase,
"left-2 top-2 translate-x-[4px] translate-y-[4px] border-l border-t",
active && "translate-x-0 translate-y-0 opacity-100",
)}
/>
<span
aria-hidden="true"
className={cn(
cornerBase,
"bottom-2 right-2 -translate-x-[4px] -translate-y-[4px] border-b border-r",
active && "translate-x-0 translate-y-0 opacity-100",
)}
/>
</>
);
}
export function Carousel({ items, variant = "facet", className, autoPlayMs = 3600 }: CarouselProps) {
if (variant === "plate") return <PlateCarousel items={items} className={className} />;
return <TransformCarousel items={items} variant={variant} className={className} autoPlayMs={autoPlayMs} />;
}
// ---------------------------------------------------------------------------
// facet + orbit — comparten el mismo estado (índice activo, avance
// circular, auto-play que pausa al hover/foco) y solo difieren en la
// fórmula de transform y en el recorte de la tarjeta.
// ---------------------------------------------------------------------------
function TransformCarousel({
items,
variant,
className,
autoPlayMs,
}: {
items: CarouselItem[];
variant: "facet" | "orbit";
className?: string;
autoPlayMs: number;
}) {
const [active, setActive] = React.useState(0);
const [paused, setPaused] = React.useState(false);
const count = items.length;
React.useEffect(() => {
if (!autoPlayMs || paused || count < 2) return;
const id = setInterval(() => setActive((a) => (a + 1) % count), autoPlayMs);
return () => clearInterval(id);
}, [autoPlayMs, paused, count]);
const go = (dir: 1 | -1) => setActive((a) => (a + dir + count) % count);
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowLeft") go(-1);
if (e.key === "ArrowRight") go(1);
};
return (
<div className={cn("select-none", className)}>
<div
role="region"
aria-roledescription="carousel"
tabIndex={0}
onKeyDown={onKeyDown}
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
onFocus={() => setPaused(true)}
onBlur={() => setPaused(false)}
className={cn(
"relative mx-auto h-[420px] max-w-full outline-none focus-visible:ring-2 focus-visible:ring-primary",
variant === "orbit" && "[perspective:1400px]",
)}
>
{items.map((item, i) => {
const delta = circularDelta(i, active, count);
const abs = Math.abs(delta);
const visible = abs <= 2;
const isActive = delta === 0;
let transform: string;
if (variant === "orbit") {
const angle = Math.max(-2, Math.min(2, delta)) * 26;
const rad = (angle * Math.PI) / 180;
const radius = 240;
const x = Math.sin(rad) * radius;
const y = (1 - Math.cos(rad)) * 56;
const rotateY = -angle * 0.9;
const scale = isActive ? 1 : abs === 1 ? 0.82 : 0.68;
transform = `translate(-50%, -50%) translateX(${x}px) translateY(${y}px) rotateY(${rotateY}deg) scale(${scale})`;
} else {
const scale = isActive ? 1.06 : abs === 1 ? 0.86 : 0.74;
const y = abs * 20;
transform = `translate(-50%, -50%) translateX(${delta * 200}px) translateY(${y}px) scale(${scale})`;
}
return (
<button
key={item.title}
type="button"
aria-label={item.title}
aria-current={isActive}
onClick={() => setActive(i)}
style={{
transform,
opacity: isActive ? 1 : abs === 1 ? 0.55 : visible ? 0.22 : 0,
zIndex: 30 - abs * 10,
pointerEvents: visible ? "auto" : "none",
}}
className={cn(
"group absolute left-1/2 top-1/2 w-[260px] border border-border bg-muted text-left transition-[transform,opacity,border-color] duration-700",
EASE,
isActive && "border-primary/40",
variant === "facet" && "[clip-path:polygon(0_0,calc(100%-26px)_0,100%_26px,100%_100%,0_100%)]",
)}
>
<ActiveCorners active={isActive} />
<div className="relative aspect-[16/10] overflow-hidden border-b border-border">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={item.image}
alt=""
className={cn("h-full w-full object-cover grayscale-[35%] brightness-90 transition-[filter] duration-500", EASE, isActive && "grayscale-0 brightness-100")}
/>
</div>
<div className="flex items-baseline justify-between gap-3 p-4">
<div>
<p className="text-base font-medium leading-snug text-foreground">{item.title}</p>
{item.description && <p className="mt-1 text-xs text-muted-foreground">{item.description}</p>}
</div>
{item.index && <span className="tabular shrink-0 font-mono text-xs text-primary">{item.index}</span>}
</div>
</button>
);
})}
</div>
<div className="mt-6 flex justify-center gap-2">
<button
type="button"
aria-label="Anterior"
onClick={() => go(-1)}
className={cn(
"flex h-9 w-9 items-center justify-center rounded-full border border-border text-muted-foreground transition-colors duration-200 hover:border-primary/40 hover:text-primary",
EASE,
)}
>
<svg viewBox="0 0 16 16" className="h-3.5 w-3.5" aria-hidden="true">
<path d="M10 3L5 8L10 13" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" fill="none" />
</svg>
</button>
<button
type="button"
aria-label="Siguiente"
onClick={() => go(1)}
className={cn(
"flex h-9 w-9 items-center justify-center rounded-full border border-border text-muted-foreground transition-colors duration-200 hover:border-primary/40 hover:text-primary",
EASE,
)}
>
<svg viewBox="0 0 16 16" className="h-3.5 w-3.5" aria-hidden="true">
<path d="M6 3L11 8L6 13" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" fill="none" />
</svg>
</button>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// plate — una pieza expuesta + ficha de catálogo + tira de miniaturas.
// Sin transform de posición: solo crossfade de imagen y estado activo
// en la miniatura correspondiente.
// ---------------------------------------------------------------------------
function PlateCarousel({ items, className }: { items: CarouselItem[]; className?: string }) {
const [active, setActive] = React.useState(0);
const current = items[active];
return (
<div className={cn("mx-auto max-w-[760px]", className)}>
<div className="relative aspect-[16/10] overflow-hidden border border-border bg-muted">
{items.map((item, i) => (
// eslint-disable-next-line @next/next/no-img-element
<img
key={item.title}
src={item.image}
alt={item.title}
className={cn("absolute inset-0 h-full w-full object-cover transition-opacity duration-350", EASE, i === active ? "opacity-100" : "opacity-0")}
/>
))}
</div>
<div className="mt-5 flex flex-wrap items-baseline gap-x-5 gap-y-1 border-t border-primary/60 pt-5">
{current.index && <span className="tabular shrink-0 font-mono text-xs text-primary">N.º {current.index}</span>}
<p className="text-xl font-medium text-foreground">{current.title}</p>
{current.description && (
<p className="text-xs uppercase tracking-[0.12em] text-muted-foreground">{current.description}</p>
)}
</div>
<div className="mt-4 flex flex-wrap gap-3.5">
{items.map((item, i) => (
<button
key={item.title}
type="button"
aria-current={i === active}
onClick={() => setActive(i)}
className="group w-[110px] text-left"
>
<span
className={cn(
"block aspect-[16/10] overflow-hidden border transition-colors duration-300",
EASE,
i === active ? "border-primary/60" : "border-border",
)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={item.image}
alt=""
className={cn(
"h-full w-full object-cover grayscale-[85%] brightness-[0.6] transition-[filter] duration-300",
EASE,
"group-hover:grayscale-[40%] group-hover:brightness-[0.85]",
i === active && "grayscale-0 brightness-100",
)}
/>
</span>
<span className={cn("tabular mt-1.5 block font-mono text-[0.6875rem] tracking-[0.06em] text-muted-foreground", i === active && "text-primary")}>
{item.index ?? String(i + 1).padStart(2, "0")} — {item.title}
</span>
</button>
))}
</div>
</div>
);
}Código real de packages/ui/src/carousel.tsx.
Uso
import { Carousel } from "./components/ui/carousel";
const items = [
{ index: "01", title: "Alba", description: "Spa & bienestar", image: "/alba.webp" },
{ index: "02", title: "Fuero", description: "Despacho legal", image: "/fuero.webp" },
];
<Carousel items={items} variant="facet" />API
| Componente | Prop | Tipo | Default |
|---|---|---|---|
| Carousel | items | CarouselItem[] | — |
| variant | facet | plate | orbit | facet | |
| autoPlayMs | number | 3600 | |
| CarouselItem | title | string | — |
| — | opcional | ||
| image | string | — | |
| — | opcional — ej. "01" | ||
autoPlayMs={0} desactiva el auto-avance — usado arriba en Variantes para que las tres tarjetas no compitan por atención a la vez. No aplica a plate, que nunca avanza sola.