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>
92 lines
2.7 KiB
TypeScript
92 lines
2.7 KiB
TypeScript
'use client';
|
|
|
|
import { motion } from 'framer-motion';
|
|
import { useEffect, useMemo, useRef, useState, type ElementType } from 'react';
|
|
import { usePrefersReducedMotion } from '@/lib/motion';
|
|
|
|
// Adapted from React Bits' BlurText (reactbits.dev, MIT) — generalised to take
|
|
// several independently-styled segments instead of one plain string, so a
|
|
// gold/underlined word can blur in beside plain navy text in the same headline.
|
|
type Segmento = { text: string; className?: string };
|
|
|
|
const FROM = { filter: 'blur(10px)', opacity: 0, y: 14 };
|
|
const KEYFRAMES = {
|
|
filter: ['blur(10px)', 'blur(4px)', 'blur(0px)'],
|
|
opacity: [0, 0.6, 1],
|
|
y: [14, 4, 0],
|
|
};
|
|
const TIMES = [0, 0.5, 1];
|
|
|
|
export function TextoDesenfoque({
|
|
segments,
|
|
delayMs = 45,
|
|
stepDuration = 0.35,
|
|
className = '',
|
|
as: Tag = 'span',
|
|
}: {
|
|
segments: Segmento[];
|
|
delayMs?: number;
|
|
stepDuration?: number;
|
|
className?: string;
|
|
as?: ElementType;
|
|
}) {
|
|
const reduced = usePrefersReducedMotion();
|
|
const [inView, setInView] = useState(false);
|
|
const ref = useRef<HTMLElement>(null);
|
|
|
|
useEffect(() => {
|
|
const el = ref.current;
|
|
if (!el || reduced) return;
|
|
const observer = new IntersectionObserver(
|
|
([entry]) => {
|
|
if (entry.isIntersecting) {
|
|
setInView(true);
|
|
observer.unobserve(el);
|
|
}
|
|
},
|
|
{ threshold: 0.4 },
|
|
);
|
|
observer.observe(el);
|
|
return () => observer.disconnect();
|
|
}, [reduced]);
|
|
|
|
const words = useMemo(
|
|
() => segments.flatMap((seg, si) => seg.text.split(' ').map((w, wi) => ({ w, si, wi, className: seg.className }))),
|
|
[segments],
|
|
);
|
|
|
|
if (reduced) {
|
|
return (
|
|
<Tag ref={ref} className={className}>
|
|
{segments.map((seg, i) => (
|
|
<span key={i} className={seg.className}>
|
|
{seg.text}
|
|
{i < segments.length - 1 ? ' ' : ''}
|
|
</span>
|
|
))}
|
|
</Tag>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Tag ref={ref} className={className} style={{ display: 'inline' }}>
|
|
{words.map(({ w, si, wi, className: segClass }, i) => (
|
|
// The joining space is a sibling text node, not the span's own trailing
|
|
// character — a space at the very end of an inline-block's own content
|
|
// collapses to zero width, which silently glued every word together.
|
|
<span key={`${si}-${wi}`}>
|
|
<motion.span
|
|
className={`inline-block will-change-[transform,filter,opacity] ${segClass ?? ''}`}
|
|
initial={FROM}
|
|
animate={inView ? KEYFRAMES : FROM}
|
|
transition={{ duration: stepDuration * 2, times: TIMES, delay: (i * delayMs) / 1000 }}
|
|
>
|
|
{w}
|
|
</motion.span>
|
|
{i < words.length - 1 ? ' ' : ''}
|
|
</span>
|
|
))}
|
|
</Tag>
|
|
);
|
|
}
|