*
)
Loading
)
0%

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.

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

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.

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

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