Gryt

User Interface

Design system, themes, and accessibility

The Gryt UI is built with Radix UI components, Framer Motion for animations, and supports dark/light themes with system preference detection.

Key Features

Beautiful Design

  • Clean Interface: Modern, minimalist design with professional aesthetics
  • Radix UI Components: Professional, accessible component library
  • Responsive Layout: Works perfectly on desktop and mobile devices
  • Visual Hierarchy: Clear information architecture and navigation

Adaptive Theming

  • Dark/Light Themes: Automatic theme switching based on system preference
  • Custom Themes: Support for custom color schemes
  • High Contrast: Accessibility-friendly high contrast mode
  • Theme Persistence: Remembers user theme preference

Accessibility

  • Atkinson Hyperlegible: Purpose-built typeface for maximum legibility, designed by the Braille Institute
  • Full Keyboard Navigation: Complete keyboard accessibility
  • Screen Reader Support: ARIA labels and semantic HTML
  • Focus Management: Clear focus indicators and logical tab order
  • Color Contrast: WCAG AA compliant color contrast ratios

See the Accessibility guide for our full commitment and roadmap toward a 100% accessible platform.

Real-time Animations

  • Smooth Transitions: Framer Motion powered animations
  • Visual Feedback: Real-time speaking indicators and connection states
  • Loading States: Beautiful loading spinners and progress indicators
  • Micro-interactions: Subtle animations for better user experience

Emoji System

Standard Emoji Shortcodes

Type :smile: or any other standard shortcode in the chat input and it will be rendered as the corresponding Unicode emoji. Over 1800 standard emojis are supported via the gemoji dataset, including names, aliases, and tags.

Custom Server Emojis

Server admins can upload custom emojis (any image format; automatically resized to 128x128 PNG). Custom emojis are used with the same :shortcode: syntax and render as inline images in messages.

Emoji Autocomplete

When you type : followed by two or more characters, a popup appears above the chat input showing matching emojis. The search uses a ranked algorithm:

  1. Prefix match -- :smi matches :smile:
  2. Word-boundary match -- :up matches :thumbs_up:
  3. Substring match -- :rin matches :grinning:
  4. Tag/alias match -- :happy matches emojis tagged "happy"

Navigate with arrow keys, press Enter/Tab to insert, or click a result. Custom server emojis are included and marked with a "custom" badge.

Rich Text Input

The chat input is a contenteditable editor that renders emojis inline. Standard emojis appear as native Unicode characters and custom emojis appear as small inline images, so you can see exactly what your message will look like before sending.

Design System

Color Palette

Gryt uses a carefully crafted color palette that works across both light and dark themes:

// Color system
const colors = {
  // Primary colors
  primary: {
    50: '#f0f9ff',
    100: '#e0f2fe',
    500: '#0ea5e9',
    600: '#0284c7',
    900: '#0c4a6e'
  },
  
  // Neutral colors
  neutral: {
    50: '#fafafa',
    100: '#f5f5f5',
    500: '#737373',
    600: '#525252',
    900: '#171717'
  },
  
  // Semantic colors
  success: '#10b981',
  warning: '#f59e0b',
  error: '#ef4444',
  info: '#3b82f6'
};

// Dark theme colors
const darkColors = {
  background: '#0a0a0a',
  surface: '#1a1a1a',
  border: '#262626',
  text: '#fafafa',
  textSecondary: '#a3a3a3'
};

Typography

Gryt uses Atkinson Hyperlegible Next as its primary typeface and Atkinson Hyperlegible Mono for all monospaced text. Created by the Braille Institute of America, these fonts maximize character differentiation for users with low vision while looking great for everyone. See the Accessibility page for more on why we chose this font.

Both fonts ship as variable WOFF2 files supporting weights 200–900:

@font-face {
  font-family: "Atkinson Hyperlegible Next";
  src: url("./assets/fonts/AtkinsonHyperlegibleNextVF-Variable.woff2") format("woff2");
  font-weight: 200 900;
  font-display: swap;
}

@font-face {
  font-family: "Atkinson Hyperlegible Mono";
  src: url("./assets/fonts/AtkinsonHyperlegibleMonoVF-Variable.woff2") format("woff2");
  font-weight: 200 900;
  font-display: swap;
}

The type scale follows a consistent hierarchy:

const typography = {
  fontFamily: {
    sans: ['Atkinson Hyperlegible Next', 'sans-serif'],
    mono: ['Atkinson Hyperlegible Mono', 'monospace']
  },
  
  fontSize: {
    xs: '0.75rem',    // 12px
    sm: '0.875rem',   // 14px
    base: '1rem',     // 16px
    lg: '1.125rem',   // 18px
    xl: '1.25rem',    // 20px
    '2xl': '1.5rem',  // 24px
    '3xl': '1.875rem', // 30px
    '4xl': '2.25rem'  // 36px
  },
  
  fontWeight: {
    normal: 400,
    medium: 500,
    semibold: 600,
    bold: 700
  },
  
  lineHeight: {
    tight: 1.25,
    normal: 1.5,
    relaxed: 1.75
  }
};

Spacing System

Consistent spacing using a 4px base unit:

// Spacing scale
const spacing = {
  0: '0',
  1: '0.25rem',  // 4px
  2: '0.5rem',   // 8px
  3: '0.75rem',  // 12px
  4: '1rem',     // 16px
  5: '1.25rem',  // 20px
  6: '1.5rem',   // 24px
  8: '2rem',     // 32px
  10: '2.5rem',  // 40px
  12: '3rem',    // 48px
  16: '4rem',    // 64px
  20: '5rem',    // 80px
  24: '6rem'     // 96px
};

Component Library

Voice Controls

Professional voice control components with clear visual feedback:

// Voice control button component
const VoiceControlButton = ({ 
  type, 
  isActive, 
  onClick, 
  disabled = false 
}: VoiceControlButtonProps) => {
  const getButtonStyle = () => {
    switch (type) {
      case 'mute':
        return isActive 
          ? 'bg-red-500 hover:bg-red-600 text-white' 
          : 'bg-gray-200 hover:bg-gray-300 text-gray-700';
      case 'deafen':
        return isActive 
          ? 'bg-orange-500 hover:bg-orange-600 text-white' 
          : 'bg-gray-200 hover:bg-gray-300 text-gray-700';
      case 'disconnect':
        return 'bg-red-600 hover:bg-red-700 text-white';
      default:
        return 'bg-gray-200 hover:bg-gray-300 text-gray-700';
    }
  };
  
  return (
    <button
      className={`px-4 py-2 rounded-lg font-medium transition-colors ${getButtonStyle()}`}
      onClick={onClick}
      disabled={disabled}
      aria-label={`${type} button`}
    >
      <VoiceControlIcon type={type} isActive={isActive} />
    </button>
  );
};

User List

Clean user list with real-time status indicators:

// User list component
const UserList = ({ users, currentUser }: UserListProps) => {
  return (
    <div className="user-list">
      <h3 className="text-sm font-semibold text-gray-500 mb-3">
        Connected Users ({users.length})
      </h3>
      
      <div className="space-y-2">
        {users.map(user => (
          <UserItem
            key={user.id}
            user={user}
            isCurrentUser={user.id === currentUser.id}
          />
        ))}
      </div>
    </div>
  );
};

// Individual user item
const UserItem = ({ user, isCurrentUser }: UserItemProps) => {
  return (
    <div className={`user-item flex items-center space-x-3 p-2 rounded-lg ${
      isCurrentUser ? 'bg-blue-50 dark:bg-blue-900/20' : 'hover:bg-gray-50 dark:hover:bg-gray-800'
    }`}>
      <div className="relative">
        <Avatar src={user.avatar} alt={user.nickname} />
        {user.isSpeaking && (
          <div className="absolute -bottom-1 -right-1 w-3 h-3 bg-green-500 rounded-full animate-pulse" />
        )}
      </div>
      
      <div className="flex-1 min-w-0">
        <p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
          {user.nickname}
          {isCurrentUser && <span className="text-xs text-gray-500 ml-1">(You)</span>}
        </p>
        
        <div className="flex items-center space-x-2">
          {user.isMuted && (
            <span className="text-xs text-red-500" aria-label="Muted">
              🔇
            </span>
          )}
          {user.isDeafened && (
            <span className="text-xs text-orange-500" aria-label="Deafened">
              🔇
            </span>
          )}
          <span className="text-xs text-gray-500">
            {user.connectionState}
          </span>
        </div>
      </div>
    </div>
  );
};

Server List

Server management with status indicators:

// Server list component
const ServerList = ({ servers, currentServer, onServerSwitch }: ServerListProps) => {
  return (
    <div className="server-list">
      <h3 className="text-sm font-semibold text-gray-500 mb-3">
        Servers
      </h3>
      
      <div className="space-y-1">
        {servers.map(server => (
          <ServerItem
            key={server.id}
            server={server}
            isActive={server.id === currentServer}
            onSwitch={() => onServerSwitch(server.id)}
          />
        ))}
      </div>
      
      <button
        className="w-full mt-3 p-2 text-sm text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 border border-dashed border-gray-300 dark:border-gray-600 rounded-lg hover:border-gray-400 dark:hover:border-gray-500 transition-colors"
        onClick={() => {/* Add server logic */}}
      >
        + Add Server
      </button>
    </div>
  );
};

Theme System

Theme Provider

Comprehensive theme management with system preference detection:

// Theme provider component
const ThemeProvider = ({ children }: { children: React.ReactNode }) => {
  const [theme, setTheme] = useState<'light' | 'dark' | 'system'>('system');
  const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light');
  
  // Detect system theme preference
  useEffect(() => {
    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
    
    const handleChange = (e: MediaQueryListEvent) => {
      if (theme === 'system') {
        setResolvedTheme(e.matches ? 'dark' : 'light');
      }
    };
    
    mediaQuery.addEventListener('change', handleChange);
    handleChange(mediaQuery);
    
    return () => mediaQuery.removeEventListener('change', handleChange);
  }, [theme]);
  
  // Apply theme to document
  useEffect(() => {
    document.documentElement.setAttribute('data-theme', resolvedTheme);
  }, [resolvedTheme]);
  
  return (
    <ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
      {children}
    </ThemeContext.Provider>
  );
};

Theme Toggle

Smooth theme switching with animation:

// Theme toggle component
const ThemeToggle = () => {
  const { theme, setTheme } = useTheme();
  
  const cycleTheme = () => {
    setTheme(prev => {
      switch (prev) {
        case 'light': return 'dark';
        case 'dark': return 'system';
        case 'system': return 'light';
        default: return 'light';
      }
    });
  };
  
  return (
    <button
      onClick={cycleTheme}
      className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
      aria-label="Toggle theme"
    >
      <motion.div
        key={theme}
        initial={{ rotate: -180, opacity: 0 }}
        animate={{ rotate: 0, opacity: 1 }}
        exit={{ rotate: 180, opacity: 0 }}
        transition={{ duration: 0.2 }}
      >
        {theme === 'light' && <SunIcon className="w-5 h-5" />}
        {theme === 'dark' && <MoonIcon className="w-5 h-5" />}
        {theme === 'system' && <ComputerIcon className="w-5 h-5" />}
      </motion.div>
    </button>
  );
};

Accessibility Features

Keyboard Navigation

Complete keyboard accessibility:

// Keyboard navigation hook
const useKeyboardNavigation = () => {
  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      // Voice control shortcuts
      if (e.ctrlKey || e.metaKey) {
        switch (e.key) {
          case 'm':
            e.preventDefault();
            toggleMute();
            break;
          case 'd':
            e.preventDefault();
            toggleDeafen();
            break;
          case 'k':
            e.preventDefault();
            disconnect();
            break;
        }
      }
      
      // Server switching
      if (e.altKey) {
        switch (e.key) {
          case '1':
          case '2':
          case '3':
          case '4':
          case '5':
            e.preventDefault();
            switchToServer(parseInt(e.key) - 1);
            break;
        }
      }
    };
    
    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, []);
};

Screen Reader Support

Comprehensive ARIA labels and semantic HTML:

// Accessible voice controls
const AccessibleVoiceControls = () => {
  return (
    <div 
      role="toolbar" 
      aria-label="Voice controls"
      className="voice-controls"
    >
      <button
        role="button"
        aria-label={isMuted ? 'Unmute microphone' : 'Mute microphone'}
        aria-pressed={isMuted}
        onClick={toggleMute}
      >
        <MicIcon />
        <span className="sr-only">
          {isMuted ? 'Unmute microphone' : 'Mute microphone'}
        </span>
      </button>
      
      <button
        role="button"
        aria-label={isDeafened ? 'Undeafen speakers' : 'Deafen speakers'}
        aria-pressed={isDeafened}
        onClick={toggleDeafen}
      >
        <SpeakerIcon />
        <span className="sr-only">
          {isDeafened ? 'Undeafen speakers' : 'Deafen speakers'}
        </span>
      </button>
    </div>
  );
};

Focus Management

Clear focus indicators and logical tab order:

// Focus management hook
const useFocusManagement = () => {
  const focusableElements = useRef<HTMLElement[]>([]);
  
  useEffect(() => {
    // Get all focusable elements
    focusableElements.current = Array.from(
      document.querySelectorAll(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      )
    ) as HTMLElement[];
    
    // Set up focus trap for modal dialogs
    const handleTabKey = (e: KeyboardEvent) => {
      if (e.key === 'Tab') {
        const firstElement = focusableElements.current[0];
        const lastElement = focusableElements.current[focusableElements.current.length - 1];
        
        if (e.shiftKey) {
          if (document.activeElement === firstElement) {
            e.preventDefault();
            lastElement.focus();
          }
        } else {
          if (document.activeElement === lastElement) {
            e.preventDefault();
            firstElement.focus();
          }
        }
      }
    };
    
    document.addEventListener('keydown', handleTabKey);
    return () => document.removeEventListener('keydown', handleTabKey);
  }, []);
};

Animation System

Framer Motion Integration

Smooth animations powered by Framer Motion:

// Animated user list
const AnimatedUserList = ({ users }: { users: User[] }) => {
  return (
    <AnimatePresence>
      {users.map((user, index) => (
        <motion.div
          key={user.id}
          initial={{ opacity: 0, y: 20 }}
          animate={{ opacity: 1, y: 0 }}
          exit={{ opacity: 0, y: -20 }}
          transition={{ delay: index * 0.1 }}
          layout
        >
          <UserItem user={user} />
        </motion.div>
      ))}
    </AnimatePresence>
  );
};

// Speaking indicator animation
const SpeakingIndicator = ({ isSpeaking }: { isSpeaking: boolean }) => {
  return (
    <motion.div
      className="speaking-indicator"
      animate={{
        scale: isSpeaking ? [1, 1.2, 1] : 1,
        opacity: isSpeaking ? [0.5, 1, 0.5] : 0.5
      }}
      transition={{
        duration: 1,
        repeat: isSpeaking ? Infinity : 0,
        ease: "easeInOut"
      }}
    />
  );
};

Loading States

Beautiful loading animations:

// Loading spinner component
const LoadingSpinner = ({ size = 'md' }: { size?: 'sm' | 'md' | 'lg' }) => {
  const sizeClasses = {
    sm: 'w-4 h-4',
    md: 'w-6 h-6',
    lg: 'w-8 h-8'
  };
  
  return (
    <motion.div
      className={`${sizeClasses[size]} border-2 border-gray-200 border-t-blue-500 rounded-full`}
      animate={{ rotate: 360 }}
      transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
    />
  );
};

// Connection status animation
const ConnectionStatus = ({ status }: { status: ConnectionStatus }) => {
  const getStatusColor = () => {
    switch (status) {
      case 'connected': return 'bg-green-500';
      case 'connecting': return 'bg-yellow-500';
      case 'disconnected': return 'bg-red-500';
      default: return 'bg-gray-500';
    }
  };
  
  return (
    <div className="flex items-center space-x-2">
      <motion.div
        className={`w-2 h-2 rounded-full ${getStatusColor()}`}
        animate={{
          scale: status === 'connecting' ? [1, 1.2, 1] : 1,
          opacity: status === 'connecting' ? [0.5, 1, 0.5] : 1
        }}
        transition={{
          duration: 1,
          repeat: status === 'connecting' ? Infinity : 0
        }}
      />
      <span className="text-sm text-gray-600 dark:text-gray-400 capitalize">
        {status}
      </span>
    </div>
  );
};

Responsive Design

Mobile Optimization

Optimized layout for mobile devices:

// Responsive layout hook
const useResponsiveLayout = () => {
  const [isMobile, setIsMobile] = useState(false);
  const [isTablet, setIsTablet] = useState(false);
  
  useEffect(() => {
    const checkScreenSize = () => {
      const width = window.innerWidth;
      setIsMobile(width < 768);
      setIsTablet(width >= 768 && width < 1024);
    };
    
    checkScreenSize();
    window.addEventListener('resize', checkScreenSize);
    return () => window.removeEventListener('resize', checkScreenSize);
  }, []);
  
  return { isMobile, isTablet, isDesktop: !isMobile && !isTablet };
};

// Mobile-optimized voice controls
const MobileVoiceControls = () => {
  const { isMobile } = useResponsiveLayout();
  
  if (!isMobile) return null;
  
  return (
    <div className="fixed bottom-4 left-4 right-4 bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4">
      <div className="flex justify-center space-x-4">
        <VoiceControlButton type="mute" isActive={isMuted} onClick={toggleMute} />
        <VoiceControlButton type="deafen" isActive={isDeafened} onClick={toggleDeafen} />
        <VoiceControlButton type="disconnect" onClick={disconnect} />
      </div>
    </div>
  );
};

Touch Interactions

Touch-friendly interactions for mobile:

// Touch gesture handler
const useTouchGestures = () => {
  const [touchStart, setTouchStart] = useState<{ x: number; y: number } | null>(null);
  
  const handleTouchStart = (e: TouchEvent) => {
    const touch = e.touches[0];
    setTouchStart({ x: touch.clientX, y: touch.clientY });
  };
  
  const handleTouchEnd = (e: TouchEvent) => {
    if (!touchStart) return;
    
    const touch = e.changedTouches[0];
    const deltaX = touch.clientX - touchStart.x;
    const deltaY = touch.clientY - touchStart.y;
    
    // Swipe gestures
    if (Math.abs(deltaX) > Math.abs(deltaY)) {
      if (deltaX > 50) {
        // Swipe right - next server
        switchToNextServer();
      } else if (deltaX < -50) {
        // Swipe left - previous server
        switchToPreviousServer();
      }
    }
    
    setTouchStart(null);
  };
  
  useEffect(() => {
    document.addEventListener('touchstart', handleTouchStart);
    document.addEventListener('touchend', handleTouchEnd);
    
    return () => {
      document.removeEventListener('touchstart', handleTouchStart);
      document.removeEventListener('touchend', handleTouchEnd);
    };
  }, [touchStart]);
};

Troubleshooting

Common UI Issues

Theme not switching?

# Check system theme preference
# Verify theme provider setup
# Clear browser cache
# Check CSS custom properties

Animations not working?

# Check Framer Motion installation
# Verify animation props
# Test with reduced motion preference
# Check browser compatibility

Accessibility issues?

# Test with screen reader
# Check ARIA labels
# Verify keyboard navigation
# Test color contrast

Mobile layout problems?

# Check responsive breakpoints
# Test touch interactions
# Verify viewport meta tag
# Test on different devices

Debug Tools

Enable UI debugging:

// Enable UI debug mode
localStorage.setItem('debug', 'gryt:ui:*');

// UI metrics
const uiMetrics = {
  theme: getCurrentTheme(),
  screenSize: getScreenSize(),
  animationPerformance: getAnimationPerformance(),
  accessibilityScore: getAccessibilityScore()
};

console.log('UI Metrics:', uiMetrics);

Performance Metrics

UI Performance

Monitor UI performance:

  • First Contentful Paint: < 1.5s
  • Largest Contentful Paint: < 2.5s
  • Cumulative Layout Shift: < 0.1
  • First Input Delay: < 100ms

Accessibility Metrics

Measure accessibility compliance:

  • WCAG AA Compliance: 100%
  • Keyboard Navigation: Complete coverage
  • Screen Reader Support: Full compatibility
  • Color Contrast: 4.5:1 minimum ratio

On this page