Files
Creeer-Web/components/primitives/TextoDesenfoque.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

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>
);
}