Polaris UI
Una colección seleccionada de los componentes UI personalizados construidos para este portfolio. Están diseñados para ser altamente reutilizables, accesibles y fácilmente adaptables para futuros proyectos web. Los expongo aquí como código fuente puro para facilitar su integración, junto con vistas previas interactivas.
Sistema Tipográfico
La jerarquía tipográfica está estrictamente controlada mediante clases CSS para asegurar fluidez perfecta. Utiliza Kumbh Sans para títulos, PSL para párrafos y lectura, y NHM para menús y UI.
/* Fluid Typography Scale (Tailwind V4 @theme) */
@theme {
--font-heading: 'Kumbh Sans', sans-serif;
--font-body: 'psl', sans-serif;
--font-ui: 'nhm', sans-serif;
}
.text-heading {
font-size: clamp(3rem, 1.5rem + 4vw, 5rem);
line-height: 1.1;
letter-spacing: -0.02em;
}
.text-body {
font-size: clamp(1.125rem, 1rem + 0.5vw, 1.35rem);
line-height: 1.5;
}
.text-small {
font-size: clamp(11px, 0.8vw, 12px);
letter-spacing: 0.15em;
}Paleta de Colores
La paleta es dinámica y basada al 100% en variables CSS. Se usa color-mix() para crear variantes secundarias transparentes sin problemas de opacidad en transiciones de tema.
/* Semantic Colors (globals.css) */
:root {
--bg-primary: #EFEDEA;
--bg-secondary: #191919;
--text-primary: #191919;
--text-secondary: color-mix(in srgb, var(--text-primary) 70%, var(--bg-primary));
--text-muted: color-mix(in srgb, var(--text-primary) 40%, var(--bg-primary));
--accent: #93c5fd;
--red-accent: #d82b20;
}
[data-theme='dark'] {
--bg-primary: #191919;
--bg-secondary: #efedea;
--text-primary: #efedea;
/* Accent colors remain identical across themes */
--accent: #93c5fd;
--red-accent: #d82b20;
}Reglas de Diseño (DESIGN.md)
Las normas inquebrantables del portfolio: prohibición total del uso de opacidades en texto, escalado estrictamente fluido (nada de tamaños en px manuales), y contraste sobre el color Accent.
- 1No Text Opacities: Never use opacity utilities on text (e.g. text-white/50). Use
var(--text-secondary). - 2Fluid Typography: Never hardcode text sizes. Always use fluid utility classes like
.text-heading. - 3Contrast over Accent: Text over an accent background must always be solid black for maximum contrast.
/* STRICT PROHIBITIONS (from DESIGN.md) */
1. NO TEXT OPACITIES: Never use opacity utilities on text (e.g., text-white/50). Opacities cause rendering clashes on solid backgrounds during theme swaps. Use var(--text-secondary) or var(--text-muted) instead.
- EXCEPTION: Text sitting directly on top of photographic images.
2. FLUID TYPOGRAPHY: All text scales fluidly using clamp(). Do not hardcode pixel or rem sizes (text-sm, text-xl) for typography in components. Use the semantic utility classes.
3. ACCENT CONTRAST: Since --accent is always light blue in both modes, any text placed over an --accent background MUST use solid text-black (or #000000) to guarantee contrast.
4. NO ARBITRARY FONT WEIGHTS: Do NOT add classes like font-medium, font-semibold, or font-light to override the fluid scales. The semantic classes already define their correct font weights.Smart Button
Un componente de botón muy versátil que integra efectos magnéticos y animación de texto envolvente. Soporta múltiples variantes, tamaños y renderizado polimórfico como enlace de anclaje o botón nativo. Ahora la lógica magnética viene incrustada nativamente.
import React, { useRef, useEffect, ReactNode } from 'react';
import Link from 'next/link';
import gsap from 'gsap';
import RollingText from './RollingText';
export interface MagneticButtonProps {
children: ReactNode;
className?: string;
onClick?: () => void;
strength?: number;
disabled?: boolean;
}
export const MagneticButton = ({ children, className = '', onClick, strength = 0.4, disabled = false }: MagneticButtonProps) => {
const magneticRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = magneticRef.current;
if (!el || strength <= 0) return;
const handleMouseMove = (e: MouseEvent) => {
if (disabled) return;
const { clientX, clientY } = e;
const { left, top, width, height } = el.getBoundingClientRect();
const x = (clientX - (left + width / 2)) * strength;
const y = (clientY - (top + height / 2)) * strength;
gsap.to(el, {
x: x,
y: y,
duration: 0.6,
ease: 'power3.out',
});
};
const handleMouseLeave = () => {
gsap.to(el, {
x: 0,
y: 0,
duration: 0.8,
ease: 'elastic.out(1, 0.3)',
});
};
el.addEventListener('mousemove', handleMouseMove);
el.addEventListener('mouseleave', handleMouseLeave);
return () => {
el.removeEventListener('mousemove', handleMouseMove);
el.removeEventListener('mouseleave', handleMouseLeave);
};
}, [strength, disabled]);
return (
<div
ref={magneticRef}
onClick={!disabled ? onClick : undefined}
className={`inline-block transition-colors duration-300 ${disabled ? 'cursor-wait pointer-events-none' : 'cursor-pointer'} ${className}`}
>
{children}
</div>
);
};
interface SmartButtonProps {
text?: string;
children?: React.ReactNode;
href?: string;
onClick?: () => void;
type?: 'button' | 'submit' | 'reset';
disabled?: boolean;
variant?: 'black' | 'white' | 'transparent' | 'ghost';
size?: 'icon-sm' | 'icon-md' | 'sm' | 'md' | 'lg' | 'none';
className?: string;
textClassName?: string;
magneticStrength?: number;
target?: string;
rel?: string;
}
export default function SmartButton({
text,
children,
href,
onClick,
type = 'button',
disabled = false,
variant = 'black',
size = 'md',
className = '',
textClassName = '',
magneticStrength = 0.3,
target,
rel
}: SmartButtonProps) {
// Base classes for the button container
let baseClasses = "group flex items-center justify-center relative z-20 transition-colors duration-500 rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current shadow-sm ";
// Variant styles
if (variant === 'black') {
baseClasses += "btn-black ";
} else if (variant === 'white') {
baseClasses += "bg-white text-black border border-transparent ";
} else if (variant === 'transparent') {
baseClasses += "bg-transparent text-current shadow-none border-b border-[var(--text-primary)] pb-1 hover:border-[var(--accent)] hover:text-[var(--accent)] rounded-none ";
} else if (variant === 'ghost') {
baseClasses += "bg-transparent text-current shadow-none ";
}
// Size styles
if (size === 'icon-sm') {
baseClasses += "w-8 h-8 md:w-10 md:h-10 px-0 ";
} else if (size === 'icon-md') {
baseClasses += "w-10 h-10 md:w-12 md:h-12 px-0 ";
} else if (size === 'sm') {
baseClasses += "h-10 md:h-12 px-5 md:px-6 ";
} else if (size === 'md') {
baseClasses += "h-12 md:h-14 px-8 md:px-12 ";
} else if (size === 'lg') {
baseClasses += "h-12 md:h-16 px-12 md:px-16 ";
} else if (size === 'none') {
baseClasses += " ";
}
const combinedClasses = `${baseClasses} ${className}`.trim();
// Typography for RollingText (standardized)
const defaultTextClasses = "text-small tracking-widest mr-[-0.1em] pointer-events-none uppercase ";
const combinedTextClasses = `${defaultTextClasses} ${textClassName}`.trim();
const content = text ? (
<RollingText text={text} className={combinedTextClasses} />
) : (
children
);
const InnerElement = href ? (
<Link href={href} target={target} rel={rel} className={combinedClasses} onClick={onClick}>
{content}
</Link>
) : (
<button type={type} onClick={onClick} disabled={disabled} className={combinedClasses} style={{ opacity: disabled ? 0.5 : 1 }}>
{content}
</button>
);
if (magneticStrength > 0 && !disabled) {
return (
<MagneticButton strength={magneticStrength}>
{InnerElement}
</MagneticButton>
);
}
return InnerElement;
}
Rolling Text
Un elegante componente tipográfico basado en CSS puro que anima los caracteres secuencialmente al pasar el ratón, creando un suave efecto de rotación.
import React from 'react';
interface RollingTextProps {
text: string;
className?: string;
}
export default function RollingText({ text, className = '' }: RollingTextProps) {
return (
<span
className={`relative flex overflow-hidden leading-none ${className}`}
aria-label={text}
>
{text.split('').map((char, index) => {
const displayChar = char === ' ' ? '\u00A0' : char;
return (
<span
key={index}
data-char={displayChar}
aria-hidden="true"
className="relative inline-block transition-transform duration-500 ease-[cubic-bezier(0.76,0,0.24,1)] group-hover:-translate-y-full after:content-[attr(data-char)] after:absolute after:left-0 after:top-full"
style={{ transitionDelay: `${index * 0.03}s` }}
>
{displayChar}
</span>
);
})}
</span>
);
}
Marquesina Infinita
Una marquesina de desplazamiento infinito de alto rendimiento construida con Framer Motion, que soporta velocidades dinámicas basadas en el scroll.
import React, { useRef, useLayoutEffect, useState, useCallback, useEffect } from 'react';
import {
motion,
useScroll,
useSpring,
useTransform,
useMotionValue,
useVelocity,
useAnimationFrame
} from 'framer-motion';
interface VelocityMapping {
input: [number, number];
output: [number, number];
}
interface VelocityTextProps {
children: React.ReactNode;
baseVelocity: number;
scrollContainerRef?: React.RefObject<HTMLElement>;
className?: string;
damping?: number;
stiffness?: number;
numCopies?: number;
velocityMapping?: VelocityMapping;
parallaxClassName?: string;
scrollerClassName?: string;
parallaxStyle?: React.CSSProperties;
scrollerStyle?: React.CSSProperties;
fixedDirection?: boolean;
}
interface ScrollVelocityProps {
scrollContainerRef?: React.RefObject<HTMLElement>;
texts: React.ReactNode[];
velocity?: number;
className?: string;
damping?: number;
stiffness?: number;
numCopies?: number;
velocityMapping?: VelocityMapping;
parallaxClassName?: string;
scrollerClassName?: string;
parallaxStyle?: React.CSSProperties;
scrollerStyle?: React.CSSProperties;
// Compatibility props for original InfiniteMarquee
fontSize?: string;
baseSpeed?: number;
gap?: number;
separator?: React.ReactNode;
fixedDirection?: boolean;
}
function useElementWidth<T extends HTMLElement>(): [React.RefCallback<T>, number] {
const [width, setWidth] = useState(0);
const observerRef = useRef<ResizeObserver | null>(null);
const elementRef = useRef<T | null>(null);
const ref = useCallback((node: T | null) => {
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
elementRef.current = node;
if (node) {
const updateWidth = () => {
setWidth(node.getBoundingClientRect().width || node.offsetWidth);
};
updateWidth();
const observer = new ResizeObserver(() => {
updateWidth();
});
observer.observe(node);
observerRef.current = observer;
}
}, []);
useEffect(() => {
const handleResize = () => {
if (elementRef.current) {
setWidth(elementRef.current.getBoundingClientRect().width || elementRef.current.offsetWidth);
}
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
if (observerRef.current) {
observerRef.current.disconnect();
}
};
}, []);
return [ref, width];
}
function VelocityText({
children,
baseVelocity,
scrollContainerRef,
className = '',
damping,
stiffness,
numCopies,
velocityMapping,
parallaxClassName,
scrollerClassName,
parallaxStyle,
scrollerStyle,
fixedDirection
}: VelocityTextProps) {
const baseX = useMotionValue(0);
const scrollOptions = scrollContainerRef ? { container: scrollContainerRef } : {};
const { scrollY } = useScroll(scrollOptions);
const scrollVelocity = useVelocity(scrollY);
const smoothVelocity = useSpring(scrollVelocity, {
damping: damping ?? 60,
stiffness: stiffness ?? 300
});
const velocityFactor = useTransform(
smoothVelocity,
velocityMapping?.input || [0, 1000],
velocityMapping?.output || [0, 5],
{ clamp: false }
);
const [copyRef, copyWidth] = useElementWidth<HTMLSpanElement>();
const [windowWidth, setWindowWidth] = useState(typeof window !== 'undefined' ? window.innerWidth : 1920);
useEffect(() => {
const handleResize = () => {
setWindowWidth(window.innerWidth);
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const copiesCount = React.useMemo(() => {
if (copyWidth === 0) return numCopies ?? 8;
const needed = Math.ceil(windowWidth / copyWidth) + 3;
return Math.max(needed, 4);
}, [windowWidth, copyWidth, numCopies]);
function wrap(min: number, max: number, v: number): number {
const range = max - min;
const mod = (((v - min) % range) + range) % range;
return mod + min;
}
const x = useTransform(baseX, v => {
if (copyWidth === 0) return '0px';
return `${wrap(-copyWidth, 0, v)}px`;
});
const directionFactor = useRef<number>(1);
useAnimationFrame((t, delta) => {
let moveBy = directionFactor.current * baseVelocity * (delta / 1000);
if (!fixedDirection) {
if (velocityFactor.get() < 0) {
directionFactor.current = -1;
} else if (velocityFactor.get() > 0) {
directionFactor.current = 1;
}
}
moveBy += directionFactor.current * moveBy * velocityFactor.get();
baseX.set(baseX.get() + moveBy);
});
const spans = [];
for (let i = 0; i < copiesCount; i++) {
spans.push(
<span className={`flex-shrink-0 flex items-center ${className}`} key={i} ref={i === 0 ? copyRef : null}>
{children}
</span>
);
}
return (
<div className={`${parallaxClassName} relative overflow-hidden`} style={parallaxStyle}>
<motion.div
className={`${scrollerClassName} flex whitespace-nowrap text-center py-5`}
style={{ x, ...scrollerStyle }}
>
{spans}
</motion.div>
</div>
);
}
export const ScrollVelocity: React.FC<ScrollVelocityProps> = ({
scrollContainerRef,
texts = [],
velocity = 100,
className = '',
damping = 60,
stiffness = 300,
numCopies = 6,
velocityMapping = { input: [0, 1000], output: [0, 5] },
parallaxClassName,
scrollerClassName,
parallaxStyle,
scrollerStyle,
// Compatibility props
fontSize = '',
baseSpeed,
gap,
separator,
fixedDirection
}) => {
// If baseSpeed is provided, scale the velocity accordingly
const actualVelocity = baseSpeed !== undefined ? baseSpeed * 300 : velocity;
// Combine className and fontSize for layout compatibility
const combinedClassName = `${fontSize} ${className}`.trim();
// Merge the texts array into a single row separated by a gap (or custom separator)
const processedTexts = React.useMemo(() => {
const gapValue = gap ?? 2;
const result: React.ReactNode[] = [];
texts.forEach((text, i) => {
// Add the text element
result.push(
<span
key={`txt-${i}`}
className="inline-flex items-center"
style={!separator ? { paddingRight: `${gapValue}rem` } : undefined}
>
{text}
</span>
);
// If separator is provided, add the separator element
if (separator) {
result.push(
<span
key={`sep-${i}`}
className="inline-flex items-center justify-center animate-spin-slow"
style={{
paddingLeft: `${gapValue}rem`,
paddingRight: `${gapValue}rem`
}}
>
{separator}
</span>
);
}
});
return [result];
}, [texts, separator, gap]);
return (
// Fix #11: Use div instead of section (section requires a heading and is a landmark)
// aria-hidden="true" because this is decorative scrolling text
<div aria-hidden="true">
{processedTexts.map((text, index) => (
<VelocityText
key={index}
className={combinedClassName}
baseVelocity={index % 2 !== 0 ? -actualVelocity : actualVelocity}
scrollContainerRef={scrollContainerRef}
damping={damping}
stiffness={stiffness}
numCopies={numCopies}
velocityMapping={velocityMapping}
parallaxClassName={parallaxClassName}
scrollerClassName={scrollerClassName}
parallaxStyle={parallaxStyle}
scrollerStyle={scrollerStyle}
fixedDirection={fixedDirection}
>
{text}
</VelocityText>
))}
</div>
);
};
export default ScrollVelocity;
Animación de Texto (Title)
Un custom hook de React que utiliza GSAP y SplitType. La variante Title anima las palabras individualmente de abajo hacia arriba de forma explosiva.
Título
import { useEffect } from 'react';
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import SplitType from 'split-type';
if (typeof window !== "undefined") {
gsap.registerPlugin(ScrollTrigger);
}
export type TextAnimationType = 'title' | 'body';
export interface UseTextAnimationProps {
selector: string;
triggerSelector?: string;
type?: TextAnimationType;
containerRef?: React.RefObject<HTMLElement | null>;
dependencies?: any[];
start?: string;
delay?: number;
}
export const useTextAnimation = ({
selector,
triggerSelector,
type = 'title',
containerRef,
dependencies = [],
start = 'top 85%',
delay = 0,
}: UseTextAnimationProps) => {
useEffect(() => {
let ctx: gsap.Context;
// We use a small timeout to ensure DOM paints and fonts load before SplitType calculates positions
const timer = setTimeout(() => {
ctx = gsap.context(() => {
const trigger = triggerSelector || selector;
if (type === 'title') {
const split = new SplitType(selector, { types: 'words' });
if (split.words && split.words.length > 0) {
gsap.from(split.words, {
yPercent: 120,
opacity: 0,
stagger: 0.1,
duration: 1.5,
delay,
ease: 'expo.out',
clearProps: 'transform',
scrollTrigger: {
trigger,
start,
toggleActions: 'play none none reverse'
}
});
}
} else if (type === 'body') {
const split = new SplitType(selector, { types: 'words' });
if (split.words && split.words.length > 0) {
gsap.from(split.words, {
opacity: 0,
y: 15,
stagger: 0.04,
duration: 0.8,
delay,
ease: 'power2.out',
scrollTrigger: {
trigger,
start,
toggleActions: 'play none none reverse'
}
});
}
}
}, containerRef || undefined);
}, 100);
return () => {
clearTimeout(timer);
if (ctx) ctx.revert();
};
}, dependencies); // eslint-disable-line react-hooks/exhaustive-deps
};
Animación de Texto (Body)
La variante Body anima párrafos enteros con un suave fundido en opacidad y desplazamiento, perfectamente ajustado para lecturas largas.
Este es un bloque de texto (body) que se anima revelando cada palabra de forma escalonada desde abajo hacia arriba creando un efecto de cascada.
import { useEffect } from 'react';
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import SplitType from 'split-type';
if (typeof window !== "undefined") {
gsap.registerPlugin(ScrollTrigger);
}
export type TextAnimationType = 'title' | 'body';
export interface UseTextAnimationProps {
selector: string;
triggerSelector?: string;
type?: TextAnimationType;
containerRef?: React.RefObject<HTMLElement | null>;
dependencies?: any[];
start?: string;
delay?: number;
}
export const useTextAnimation = ({
selector,
triggerSelector,
type = 'title',
containerRef,
dependencies = [],
start = 'top 85%',
delay = 0,
}: UseTextAnimationProps) => {
useEffect(() => {
let ctx: gsap.Context;
// We use a small timeout to ensure DOM paints and fonts load before SplitType calculates positions
const timer = setTimeout(() => {
ctx = gsap.context(() => {
const trigger = triggerSelector || selector;
if (type === 'title') {
const split = new SplitType(selector, { types: 'words' });
if (split.words && split.words.length > 0) {
gsap.from(split.words, {
yPercent: 120,
opacity: 0,
stagger: 0.1,
duration: 1.5,
delay,
ease: 'expo.out',
clearProps: 'transform',
scrollTrigger: {
trigger,
start,
toggleActions: 'play none none reverse'
}
});
}
} else if (type === 'body') {
const split = new SplitType(selector, { types: 'words' });
if (split.words && split.words.length > 0) {
gsap.from(split.words, {
opacity: 0,
y: 15,
stagger: 0.04,
duration: 0.8,
delay,
ease: 'power2.out',
scrollTrigger: {
trigger,
start,
toggleActions: 'play none none reverse'
}
});
}
}
}, containerRef || undefined);
}, 100);
return () => {
clearTimeout(timer);
if (ctx) ctx.revert();
};
}, dependencies); // eslint-disable-line react-hooks/exhaustive-deps
};
Modal (Ventana Emergente)
Un modal superpuesto personalizable construido con Framer Motion, que soporta desenfoque de fondo y navegación por teclado.
import React, { useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
export interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
className?: string;
hideCloseButton?: boolean;
}
export default function Modal({
isOpen,
onClose,
title,
children,
className = '',
hideCloseButton = false
}: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose();
}
};
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
// Lock body scroll
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = '';
};
}, [isOpen, onClose]);
return (
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-[1000] flex items-center justify-center p-4 sm:p-6">
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
className="absolute inset-0 bg-black/60 backdrop-blur-md cursor-pointer"
onClick={onClose}
aria-hidden="true"
/>
{/* Modal Panel */}
<motion.div
ref={modalRef}
initial={{ opacity: 0, y: 30, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className={`relative w-full max-w-2xl max-h-[90vh] overflow-y-auto bg-[var(--bg-secondary)] border border-[var(--bg-primary)]/10 text-[var(--bg-primary)] p-6 md:p-8 rounded-[var(--radius-lg)] shadow-2xl flex flex-col gap-6 ${className}`}
role="dialog"
aria-modal="true"
aria-labelledby={title ? "modal-title" : undefined}
>
{/* Header */}
{(title || !hideCloseButton) && (
<div className="flex items-center justify-between w-full border-b border-[var(--bg-primary)]/10 pb-4 mb-2">
{title ? (
<h3 id="modal-title" className="text-body font-bold m-0 uppercase tracking-wide">
{title}
</h3>
) : <div />}
{!hideCloseButton && (
<button
onClick={onClose}
className="p-2 -mr-2 text-[var(--bg-primary)]/70 hover:text-[var(--accent)] transition-colors focus:outline-none"
aria-label="Close modal"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
)}
</div>
)}
{/* Content */}
<div className="flex-1 w-full text-body text-[var(--text-secondary)]">
{children}
</div>
</motion.div>
</div>
)}
</AnimatePresence>
);
}
