← Componentes

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

Variant
<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.

AlbaFueroSedaVértice
N.º 01

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

ComponentePropTipoDefault
CarouselitemsCarouselItem[]—
variantfacet | plate | orbitfacet
autoPlayMsnumber3600
CarouselItemtitlestring—
—opcional
imagestring—
—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.