*
)
Loading
)
0%

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.

.text-large (Kumbh Sans)
HERO TITLE
.text-heading (Kumbh Sans)
Section Title
.text-body (PSL)
PSL is used for long-form reading, paragraphs, and general text.
.text-small (NHM)
NHM for UI elements and buttons
/* 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.

bg-primary
bg-secondary
accent
red-accent
/* 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>
  );
}