Files
Creeer-Web/components/primitives/Carrusel.tsx
T
erikghandClaude Opus 5 5b400cb161 feat(primitivas): capa gráfica y de movimiento reutilizable
Diez primitivas que dan a la landing el "gráfico y movimiento" que pidió
la dueña, todas con guarda de prefers-reduced-motion:

- FondoPuntos / PatronPuntos — el patrón de puntos del manual
- TarjetaSpotlight, TextoDesenfoque, ContadorNumero — realce y reveal
- Carrusel — deslizable, con contador, autoplay pausable
- Icono, SimboloTresLineas, IlustracionInflexion — línea, sin relleno
- FondoFoto — duotono azul/dorado sobre foto

useAutoStep en lib/motion.ts comparte la forma del autoplay del carrusel
para indicadores de paso que no son carrusel.

IlustracionInflexion documenta un detalle que muerde: motion.path aplica
su propio andamiaje de dasharray aunque no reciba initial/animate, así
que la rama de reduced motion renderiza <path> planos en vez de intentar
neutralizar los animados.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 10:44:28 -06:00

91 lines
3.6 KiB
TypeScript

'use client';
import { useCallback, useEffect, useState, type ReactNode } from 'react';
import useEmblaCarousel from 'embla-carousel-react';
import Autoplay from 'embla-carousel-autoplay';
import { usePrefersReducedMotion } from '@/lib/motion';
// Thin Embla wrapper shared by the hero's pain-point rotator and the mobile
// row of inflection points. Autoplay pauses on hover/focus/interaction, and
// never runs at all for users who asked for less motion — it's still a fully
// usable carousel (drag, dots, keyboard) with it off, just static.
export function Carrusel({
slides,
autoplayMs,
className = '',
slideClassName = '',
ariaLabel,
mostrarContador = false,
}: {
slides: ReactNode[];
autoplayMs?: number;
className?: string;
slideClassName?: string;
ariaLabel: string;
// "01 / 06" + flechas prev/next, en vez de puntos — el patrón de
// en-ti.com ("desliza para explorar") para carruseles con más peso.
mostrarContador?: boolean;
}) {
const reduced = usePrefersReducedMotion();
const plugins = autoplayMs && !reduced ? [Autoplay({ delay: autoplayMs, stopOnInteraction: false, stopOnMouseEnter: true })] : [];
const [emblaRef, embla] = useEmblaCarousel({ loop: true }, plugins);
const [selected, setSelected] = useState(0);
useEffect(() => {
if (!embla) return;
const onSelect = () => setSelected(embla.selectedScrollSnap());
embla.on('select', onSelect);
onSelect();
return () => {
embla.off('select', onSelect);
};
}, [embla]);
const scrollTo = useCallback((i: number) => embla?.scrollTo(i), [embla]);
const scrollPrev = useCallback(() => embla?.scrollPrev(), [embla]);
const scrollNext = useCallback(() => embla?.scrollNext(), [embla]);
return (
<div className={`min-w-0 ${className}`}>
<div className="overflow-hidden" ref={emblaRef} role="group" aria-label={ariaLabel}>
<div className="flex">
{slides.map((slide, i) => (
<div key={i} className={`min-w-0 shrink-0 grow-0 basis-full ${slideClassName}`}>
{slide}
</div>
))}
</div>
</div>
{slides.length > 1 && mostrarContador && (
<div className="mt-6 flex items-center justify-center gap-5">
<button type="button" onClick={scrollPrev} aria-label="Anterior"
className="grid place-items-center w-9 h-9 rounded-full border border-azul-profundo/20 text-azul-profundo transition-colors hover:border-dorado hover:text-dorado">
←
</button>
<p className="font-sans font-bold text-sm tabular-nums text-azul-profundo tracking-wider">
{String(selected + 1).padStart(2, '0')} <span className="text-gris-medio font-normal">/ {String(slides.length).padStart(2, '0')}</span>
</p>
<button type="button" onClick={scrollNext} aria-label="Siguiente"
className="grid place-items-center w-9 h-9 rounded-full border border-azul-profundo/20 text-azul-profundo transition-colors hover:border-dorado hover:text-dorado">
→
</button>
</div>
)}
{slides.length > 1 && !mostrarContador && (
<div className="mt-5 flex justify-center gap-2">
{slides.map((_, i) => (
<button
key={i}
type="button"
onClick={() => scrollTo(i)}
aria-label={`Ir a la tarjeta ${i + 1} de ${slides.length}`}
aria-current={selected === i}
className={`h-2 rounded-full transition-all duration-300 ${selected === i ? 'w-6 bg-dorado' : 'w-2 bg-gris-medio/50 hover:bg-gris-medio'}`}
/>
))}
</div>
)}
</div>
);
}