Design System Documentation for KanaDojo
A comprehensive guide to UI development, theming, and best practices for our TypeScript Next.js application using Tailwind CSS.
- Project Stack
- Current Approach
- Theming System
- Accessibility Guidelines
- Component Patterns
- shadcn/ui Adoption Strategy
- Best Practices
- Code Examples
KanaDojo is built with modern web technologies optimized for performance and developer experience:
- TypeScript - Type-safe development
- Next.js 15 - App Router with React 19
- Tailwind CSS - Utility-first styling
- shadcn/ui - High-quality, accessible component library (slow adoption in progress)
- Framer Motion - Smooth animations via
motionpackage - Zustand - Lightweight state management
- Lucide React - Icon system
clsx+tailwind-merge- Conditional class management viacn()utilityclass-variance-authority- Component variant management@radix-ui- Accessible component primitives (via shadcn/ui)
KanaDojo leverages Tailwind CSS as the primary styling solution with a heavy emphasis on CSS custom properties (CSS variables) for theming. This approach provides:
- Dynamic theming - Runtime theme switching without rebuilding styles
- Consistency - Centralized color and spacing values
- Flexibility - Easy theme creation and customization
✅ DO:
<div className="bg-[var(--card-color)] text-[var(--main-color)]">
Content
</div>❌ DON'T:
<div className="bg-gray-100 text-black">
Content
</div>We use the cn() utility from lib/utils.ts to merge Tailwind classes intelligently:
import { cn } from '@/lib/utils';
<button className={cn(
"px-4 py-2 rounded-lg",
isActive && "bg-[var(--main-color)]",
disabled && "opacity-50 cursor-not-allowed"
)}>
Click me
</button>Common patterns are extracted to static/styles.ts:
// static/styles.ts
import clsx from 'clsx';
export const cardBorderStyles = clsx(
'rounded-xl bg-[var(--card-color)]'
);
export const buttonBorderStyles = clsx(
'rounded-xl bg-[var(--card-color)] hover:cursor-pointer',
'duration-250 transition-all ease-in-out',
'hover:bg-[var(--border-color)]'
);Usage:
import { buttonBorderStyles } from '@/static/styles';
<button className={buttonBorderStyles}>
Click me
</button>Use Tailwind's responsive prefixes consistently:
<div className="flex flex-col md:flex-row gap-4 md:gap-6 lg:gap-8">
{/* Content adapts to screen size */}
</div>Breakpoints:
sm: 640pxmd: 768pxlg: 1024pxxl: 1280px2xl: 1536pxxs: 30rem (custom)3xl: 110rem (custom)
KanaDojo uses a 5-variable color system defined in app/globals.css:
:root {
/* Layout Colors */
--background-color: hsla(210, 17%, 100%, 1); /* Page background */
--card-color: hsla(210, 17%, 91%, 1); /* Card/elevated surfaces */
--border-color: hsla(210, 17%, 76%, 1); /* Borders and dividers */
/* Content Colors */
--main-color: hsl(0, 0%, 0%); /* Primary text and actions */
--secondary-color: hsl(0, 0%, 35%); /* Secondary text and icons */
}Themes are defined in static/themes.ts with TypeScript interfaces:
interface Theme {
id: string;
backgroundColor: string; // Page background
cardColor: string; // Cards, modals, elevated surfaces
borderColor: string; // Borders, dividers, hover states
mainColor: string; // Primary text, icons, CTAs
secondaryColor: string; // Secondary text, subtle elements
}- Theme Definition - Themes are organized into groups (Base, Light, Dark) in
static/themes.ts - Theme Storage - Selected theme ID is persisted via Zustand in
store/useThemeStore.ts - Theme Application - The
applyTheme()function dynamically updates CSS variables:
export function applyTheme(themeId: string) {
const theme = themeMap.get(themeId);
if (!theme) return;
const root = document.documentElement;
root.style.setProperty('--background-color', theme.backgroundColor);
root.style.setProperty('--card-color', theme.cardColor);
root.style.setProperty('--border-color', theme.borderColor);
root.style.setProperty('--main-color', theme.mainColor);
root.style.setProperty('--secondary-color', theme.secondaryColor);
root.setAttribute('data-theme', theme.id);
}To add a new theme:
- Define the theme in
static/themes.ts:
{
id: 'my-custom-theme',
backgroundColor: 'hsla(220, 20%, 12%, 1)',
cardColor: 'hsla(220, 20%, 18%, 1)',
borderColor: 'hsla(220, 20%, 30%, 1)',
mainColor: 'hsla(280, 80%, 65%, 1)',
secondaryColor: 'hsla(180, 70%, 55%, 1)'
}- Test contrast ratios (see Accessibility section)
- Add to appropriate theme group (Base, Light, or Dark)
- Use HSLA for flexibility:
hsla(hue, saturation%, lightness%, alpha) - HSL makes it easier to create harmonious color schemes
- Theme IDs should be descriptive:
'midnight-purple','sunset-orange' - Use kebab-case for consistency
backgroundColor→ Lightest/darkest (depending on light/dark theme)cardColor→ Slightly elevated from backgroundborderColor→ More prominent than card, used for hover statesmainColor→ High contrast with backgroundsecondaryColor→ Medium contrast, complementary to main
Example Hierarchy (Dark Theme):
backgroundColor: hsl(220, 15%, 10%) ← Darkest
cardColor: hsl(220, 15%, 15%) ← Slightly lighter
borderColor: hsl(220, 15%, 25%) ← More prominent
mainColor: hsl(280, 80%, 70%) ← Vibrant, high contrast
secondaryColor: hsl(180, 60%, 60%) ← Complementary accent
All themes MUST meet WCAG 2.1 Level AA standards:
- Normal text (< 18pt): Contrast ratio ≥ 4.5:1
- Large text (≥ 18pt or ≥ 14pt bold): Contrast ratio ≥ 3:1
- UI components (buttons, borders): Contrast ratio ≥ 3:1
Tools for Testing:
- WebAIM Contrast Checker
- Chrome DevTools Lighthouse
- Browser extension: WAVE or axe DevTools
Example Validation:
// Test main text readability
mainColor (hsl(280, 80%, 70%)) on backgroundColor (hsl(220, 15%, 10%))
→ Contrast ratio: 8.2:1 ✅ Passes AA and AAA
// Test secondary text readability
secondaryColor (hsl(180, 60%, 60%)) on backgroundColor (hsl(220, 15%, 10%))
→ Contrast ratio: 5.1:1 ✅ Passes AAAll interactive elements MUST have visible focus indicators:
// Example from components/ui/button.tsx
const buttonVariants = cva(
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--main-color)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--background-color)]",
// ... other styles
);Focus Ring Guidelines:
- Use
focus-visible:ring-2for keyboard navigation - Ring color:
ring-[var(--main-color)] - Ring offset:
ring-offset-2for separation - Ring offset color:
ring-offset-[var(--background-color)]
Use proper HTML elements for their intended purpose:
✅ DO:
<button onClick={handleClick}>Submit</button>
<a href="/about">Learn More</a>❌ DON'T:
<div onClick={handleClick}>Submit</div>
<span onClick={navigate}>Learn More</span>Provide context for screen readers when visual cues aren't sufficient:
<button aria-label="Close modal">
<X size={20} />
</button>
<input
type="checkbox"
aria-checked={isSelected}
aria-label="Select all Hiragana characters"
/>- Ensure all interactive elements are keyboard accessible
- Support
Tab,Enter,Space,Escape, and arrow keys where appropriate - Maintain logical tab order
Follow these patterns for consistency:
'use client'; // Add for client components (state, effects, etc.)
import { useState } from 'react';
import { cn } from '@/lib/utils';
import useThemeStore from '@/store/useThemeStore';
interface MyComponentProps {
title: string;
isActive?: boolean;
onClick?: () => void;
}
const MyComponent = ({ title, isActive = false, onClick }: MyComponentProps) => {
const [state, setState] = useState('');
const theme = useThemeStore(state => state.theme);
return (
<div className={cn(
"rounded-xl p-4",
"bg-[var(--card-color)] text-[var(--main-color)]",
isActive && "border-2 border-[var(--main-color)]"
)}>
<h3 className="text-xl font-semibold">{title}</h3>
{/* ... */}
</div>
);
};
export default MyComponent;<div className="container mx-auto max-w-7xl px-4 md:px-6 lg:px-8">
{/* Responsive container with proper padding */}
</div><div className="rounded-xl bg-[var(--card-color)] p-6 shadow-sm">
{/* Card content */}
</div><button className={cn(
"px-6 py-3 rounded-lg",
"bg-[var(--main-color)] text-[var(--background-color)]",
"hover:brightness-110 active:brightness-95",
"transition-all duration-200",
"focus-visible:ring-2 focus-visible:ring-[var(--main-color)] focus-visible:ring-offset-2"
)}>
Action
</button><h1 className="text-4xl md:text-5xl font-bold text-[var(--main-color)]">
Main Heading
</h1>
<h2 className="text-2xl md:text-3xl font-semibold text-[var(--main-color)]">
Subheading
</h2>
<p className="text-base text-[var(--secondary-color)]">
Body text with secondary color
</p><div className={cn(
"p-4 rounded-lg",
"bg-[var(--card-color)]",
"hover:bg-[var(--border-color)] hover:cursor-pointer",
"transition-all duration-200"
)}>
{/* Hoverable content */}
</div>Use Framer Motion (motion package) for complex animations:
import { motion } from 'motion/react';
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
{/* Animated content */}
</motion.div>Use Tailwind transitions for simple interactions:
<button className="transition-all duration-200 hover:scale-105 active:scale-95">
Click me
</button>KanaDojo is gradually adopting shadcn/ui for consistent, accessible components.
- ✅
Button(components/ui/button.tsx) - ✅
Select(components/ui/select.tsx)
shadcn/ui components are customized to use our CSS variable system:
// components/ui/button.tsx
const buttonVariants = cva(
"bg-[var(--main-color)] text-[var(--background-color)]", // Uses our theme
// ...
);Use the shadcn CLI to add components:
npx shadcn@latest add [component-name]After installation:
- Review the generated component
- Replace hardcoded colors with CSS variables:
bg-primary→bg-[var(--main-color)]text-primary-foreground→text-[var(--background-color)]border→border-[var(--border-color)]
- Test with multiple themes to ensure compatibility
Example: Customizing Button variants
// components/ui/button.tsx
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-transparent text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--main-color)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--background-color)] disabled:pointer-events-none disabled:opacity-60",
{
variants: {
variant: {
default:
"bg-[var(--main-color)] text-[var(--background-color)] shadow-[0_10px_30px_-12px_rgba(0,0,0,0.45)] hover:brightness-110",
outline:
"border border-[var(--border-color)] bg-transparent text-[var(--main-color)] hover:bg-[var(--card-color)]",
ghost:
"bg-transparent text-[var(--main-color)] hover:bg-[var(--card-color)]",
// Add custom variants as needed
},
size: {
default: "h-10 px-5",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-12 rounded-xl px-8 text-base",
icon: "h-10 w-10",
},
},
}
);Phase 1: High-Priority Components (Current)
- ✅ Button
- ✅ Select
- 🔄 Input (next)
- 🔄 Checkbox (next)
Phase 2: Form Components
- 🔜 Form wrapper
- 🔜 Label
- 🔜 Textarea
- 🔜 Switch
Phase 3: Overlay Components
- 🔜 Dialog/Modal
- 🔜 Dropdown Menu
- 🔜 Tooltip
Migration Guidelines:
- Don't break existing UI - Migrate incrementally, one component at a time
- Test thoroughly - Verify all game modes and features still work
- Maintain theme compatibility - Always use CSS variables
- Update documentation - Document new shadcn components in this file
DO:
- ✅ Use CSS variables consistently
- ✅ Follow Radix UI patterns (shadcn is built on Radix)
- ✅ Use the
cn()utility for class management - ✅ Maintain semantic HTML structure
DON'T:
- ❌ Hardcode colors or theme values
- ❌ Create custom components that duplicate shadcn functionality
- ❌ Override Radix UI accessibility features
✅ DO:
// Use CSS variables for dynamic theming
<div className="bg-[var(--card-color)] text-[var(--main-color)]" />
// Use cn() for conditional classes
<button className={cn(
"px-4 py-2",
isActive && "bg-[var(--main-color)]"
)} />
// Extract repeated patterns to static/styles.ts
import { buttonBorderStyles } from '@/static/styles';
// Use responsive prefixes consistently
<div className="flex flex-col md:flex-row lg:gap-8" />❌ DON'T:
// Don't hardcode colors
<div className="bg-gray-100 text-black" />
// Don't use string concatenation for classes
<button className={"px-4 py-2 " + (isActive ? "bg-blue-500" : "")} />
// Don't repeat complex class strings
<button className="rounded-xl bg-[var(--card-color)] hover:bg-[var(--border-color)] transition-all duration-200" />
<div className="rounded-xl bg-[var(--card-color)] hover:bg-[var(--border-color)] transition-all duration-200" />
// Don't use arbitrary breakpoint values
<div className="flex flex-col min-[850px]:flex-row" /> // Use md: instead✅ DO:
// Use semantic heading levels
<h1 className="text-4xl font-bold">Main Title</h1>
<h2 className="text-2xl font-semibold">Section Title</h2>
// Use secondary color for less prominent text
<p className="text-[var(--secondary-color)]">Helper text</p>
// Use responsive text sizes
<h1 className="text-3xl md:text-4xl lg:text-5xl">Responsive Title</h1>❌ DON'T:
// Don't skip heading levels
<h1>Title</h1>
<h3>Skipped h2</h3>
// Don't use hardcoded gray values
<p className="text-gray-500">Text</p>
// Don't use fixed sizes that don't scale
<p className="text-[14px]">Fixed size text</p>✅ DO:
// Use Tailwind spacing scale
<div className="p-4 md:p-6 lg:p-8" />
<div className="space-y-4" /> // Consistent vertical spacing
<div className="gap-4 md:gap-6" /> // Responsive gaps in flex/grid
// Use consistent spacing within components
const spacing = "p-6 space-y-4";❌ DON'T:
// Don't use arbitrary values unnecessarily
<div className="p-[17px] space-y-[23px]" />
// Don't mix spacing units
<div className="p-4 mb-[2rem]" /> // Inconsistent✅ DO:
// Add hover states for interactive elements
<button className="hover:brightness-110 transition-duration-200" />
// Use focus-visible for keyboard navigation
<button className="focus-visible:ring-2 focus-visible:ring-[var(--main-color)]" />
// Provide feedback on active/pressed states
<button className="active:brightness-95" />
// Use appropriate cursor styles
<div className="cursor-pointer" onClick={handler} />❌ DON'T:
// Don't forget hover states
<button onClick={handler}>No hover feedback</button>
// Don't use focus instead of focus-visible (affects mouse users)
<button className="focus:ring-2" />
// Don't make non-interactive elements look clickable
<div className="cursor-pointer">Not actually clickable</div>✅ DO:
// Group related components
components/
Settings/
Themes.tsx
Preferences.tsx
Dojo/
Kana/
Kanji/
Vocab/
// Use TypeScript interfaces from lib/interfaces.ts
import { KanaCharacter } from '@/lib/interfaces';
// Use custom hooks from lib/hooks/
import { useClick } from '@/lib/hooks/useAudio';❌ DON'T:
// Don't create deeply nested component structures
components/
Settings/
ThemeSection/
ThemesList/
ThemeItem/
index.tsx // Too deep
// Don't duplicate interface definitions
interface KanaCharacter { } // Already in lib/interfaces.ts- Use
'use client'directive wisely - Only add to components that need client-side features (state, effects, event handlers) - Optimize images - Use Next.js Image component for automatic optimization
- Lazy load heavy components - Use dynamic imports for large components
- Memoize expensive calculations - Use
useMemoanduseCallbackappropriately - Minimize re-renders - Use Zustand selectors to subscribe to specific state slices
'use client';
import { cn } from '@/lib/utils';
import { cardBorderStyles } from '@/static/styles';
interface CardProps {
title: string;
description?: string;
children?: React.ReactNode;
className?: string;
}
const Card = ({ title, description, children, className }: CardProps) => {
return (
<div className={cn(
cardBorderStyles,
"p-6 space-y-4",
className
)}>
<div className="space-y-2">
<h3 className="text-xl font-semibold text-[var(--main-color)]">
{title}
</h3>
{description && (
<p className="text-sm text-[var(--secondary-color)]">
{description}
</p>
)}
</div>
{children}
</div>
);
};
export default Card;'use client';
import { cn } from '@/lib/utils';
import { buttonBorderStyles } from '@/static/styles';
import { useClick } from '@/lib/hooks/useAudio';
import { Check } from 'lucide-react';
interface SelectionButtonProps {
label: string;
isSelected: boolean;
onToggle: () => void;
}
const SelectionButton = ({ label, isSelected, onToggle }: SelectionButtonProps) => {
const { playClick } = useClick();
const handleClick = () => {
playClick();
onToggle();
};
return (
<button
onClick={handleClick}
className={cn(
buttonBorderStyles,
"p-4 flex items-center justify-between gap-3",
"transition-all duration-200",
isSelected && "border-2 border-[var(--main-color)]"
)}
aria-pressed={isSelected}
>
<span className="text-[var(--main-color)] font-medium">
{label}
</span>
{isSelected && (
<Check className="text-[var(--secondary-color)]" size={20} />
)}
</button>
);
};
export default SelectionButton;<div className="container mx-auto px-4 md:px-6 lg:px-8 py-8">
<h1 className="text-3xl md:text-4xl font-bold text-[var(--main-color)] mb-8">
Character Selection
</h1>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 md:gap-6">
{characters.map((char) => (
<CharacterCard key={char.id} character={char} />
))}
</div>
</div>'use client';
import { motion, AnimatePresence } from 'motion/react';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
const Modal = ({ isOpen, onClose, title, children }: ModalProps) => {
return (
<AnimatePresence>
{isOpen && (
<>
{/* Overlay */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="fixed inset-0 bg-black/50 z-40"
/>
{/* Modal */}
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className={cn(
"fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-50",
"w-full max-w-md max-h-[85vh] overflow-y-auto",
"bg-[var(--background-color)] rounded-2xl shadow-2xl",
"p-6"
)}
>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold text-[var(--main-color)]">
{title}
</h2>
<button
onClick={onClose}
className={cn(
"rounded-lg p-2",
"hover:bg-[var(--card-color)]",
"transition-colors duration-200",
"focus-visible:ring-2 focus-visible:ring-[var(--main-color)]"
)}
aria-label="Close modal"
>
<X className="text-[var(--secondary-color)]" size={24} />
</button>
</div>
{/* Content */}
<div className="space-y-4">
{children}
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
};
export default Modal;The project uses custom checkbox styling in app/globals.css:
/* Custom styled checkbox using CSS variables */
input[type='checkbox'] {
appearance: none;
-webkit-appearance: none;
background-color: var(--card-color);
border: 2px solid var(--border-color);
width: 1.1em;
height: 1.1em;
border-radius: 0.25em;
display: inline-block;
position: relative;
vertical-align: middle;
cursor: pointer;
transition: border-color 0.2s, background-color 0.2s;
}
input[type='checkbox']:checked {
background-color: var(--main-color);
border-color: var(--main-color);
}
input[type='checkbox']:checked::after {
content: '';
display: block;
position: absolute;
left: 0.28em;
top: 0.05em;
width: 0.3em;
height: 0.6em;
border: solid var(--background-color);
border-width: 0 0.18em 0.18em 0;
transform: rotate(45deg);
}Usage in JSX:
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={isSelected}
onChange={(e) => setIsSelected(e.target.checked)}
className="focus-visible:ring-2 focus-visible:ring-[var(--main-color)]"
/>
<span className="text-[var(--main-color)]">Option label</span>
</label>import { Button } from '@/components/ui/button';
// Default variant - primary action
<Button onClick={handleSubmit}>
Submit
</Button>
// Outline variant - secondary action
<Button variant="outline" onClick={handleCancel}>
Cancel
</Button>
// Ghost variant - tertiary action
<Button variant="ghost" onClick={handleReset}>
Reset
</Button>
// Different sizes
<Button size="sm">Small</Button>
<Button size="default">Default</Button>
<Button size="lg">Large</Button>
<Button size="icon">
<Settings size={20} />
</Button>
// With custom classes
<Button className="w-full md:w-auto">
Responsive Width
</Button>CLAUDE.md- Project overview and architectureCONTRIBUTING.md- Contribution guidelines and code stylestatic/themes.ts- Theme definitions and managementlib/interfaces.ts- TypeScript interfacesstatic/styles.ts- Reusable style constants
- Tailwind CSS Documentation
- shadcn/ui Documentation
- Next.js Documentation
- Framer Motion Documentation
- WCAG 2.1 Guidelines
- Radix UI Documentation
- WebAIM Contrast Checker
- Coolors.co - Color palette generator
- Realtime Colors - Theme visualization
- HSL Color Picker
This document should be updated whenever:
- New theming patterns are established
- shadcn/ui components are added or customized
- CSS variable naming conventions change
- New accessibility requirements are identified
- Major design system changes are implemented
Last Updated: [Current Date]
Maintained By: KanaDojo Team
Questions or Suggestions?
Please open an issue or discussion on GitHub to improve this documentation.