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,91 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user