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>
This commit is contained in:
1 parent
a9758a92f9
commit
5b400cb161
19 files changed
+881
No files matched your search
@@ -0,0 +1,90 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user