Polaris UI
A curated collection of the custom UI components built for this portfolio. These components are designed to be highly reusable, accessible, and easily adaptable for future web projects. I have exposed them here as raw source code for quick copy-pasting, complete with interactive previews.
Typography System
The typographic hierarchy is strictly controlled via Tailwind @theme utilities and CSS classes to ensure mathematical consistency across all breakpoints. It uses Kumbh Sans for headings, PSL for body, and NHM for UI elements.
/* 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;
}Color Palette
The color palette is dynamically driven by CSS variables. Tailwind classes (e.g., bg-[var(--bg-primary)]) must be used over raw hex values to ensure seamless Dark Mode transitions.
/* 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;
}Design Rules & Guidelines
Crucial rules regarding fluid sizing, accessible contrast ratios, and theme inversion safety. Adhering to these guarantees the UI never breaks when switching modes.
- 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
A highly versatile button component that integrates magnetic hover effects and a rolling text animation. It supports multiple variants, sizes, and polymorphic rendering as either an anchor link or a native button.
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
An elegant CSS-based typography component that animates characters sequentially on hover, creating a smooth rolling effect.
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>
);
}
Infinite Marquee
A performant infinite scrolling marquee built with Framer Motion, supporting dynamic velocities and scroll direction.
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;
Text Animation (Title)
A custom React hook leveraging GSAP and SplitType to create staggered text entry animations. The Title variant animates words individually from the bottom up.
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
};
Text Animation (Body)
The Body variant animates entire paragraphs of text with a soft opacity and y-axis fade effect, perfectly tuned for long-form reading.
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 (Dialog)
A customizable overlay modal built with Framer Motion, supporting backdrop blurs, focus trapping, and keyboard navigation.
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>
);
}
