UI Primitives Reference
@doc-version: 16.1.0
@last-updated: 2026-01-01
This reference documents the foundational UI primitives that form Visitaβs design language. These components are civic-appropriate, accessible, and designed for composition.
Overview
Core Principle: Primitives are generic, accessible, and design-neutral. They form the building blocks for all domain-specific components.
What Are Primitives?
Primitives are the atomic UI components that:
- β
Are generic and reusable across all contexts
- β
Provide accessible behavior by default
- β
Follow design tokens consistently
- β
Have no business logic
- β
Live in
/components/ui/
What Primitives Are NOT
- β Domain-specific components (like
WardCard or CrimeMap)
- β Smart components with business logic
- β Page-specific layouts
- β Complex composite components
Design Tokens
Foundation Before Components
All primitives are built on these design tokens.
π¨ Color Palette (Semantic)
// Primary colors
primary β #2563eb // Civic blue (trust, stability)
secondary β #64748b // Muted neutral (backgrounds)
danger β #dc2626 // Red (alerts, crime, errors)
warning β #d97706 // Amber (caution, attention)
success β #059669 // Green (positive, confirmed)
surface β #ffffff // White (cards, panels)
muted β #6b7280 // Gray (metadata, secondary)
// Background colors
bg-primary β #ffffff // Primary background
bg-secondary β #f8fafc // Secondary background
bg-muted β #f1f5f9 // Muted background
π² Border Radius
radius-none β 0px // No radius
radius-sm β 4px // Inputs, small elements
radius-md β 8px // Cards, panels (default)
radius-lg β 12px // Modals, large containers
radius-xl β 16px // Special cases only
radius-full β 9999px // Pills, avatars
π Spacing Scale
space-0 β 0px
space-xs β 4px
space-sm β 8px
space-md β 16px
space-lg β 24px
space-xl β 32px
space-2xl β 40px
space-3xl β 48px
π Sizing Scale
size-xs β 20px // Small icons
size-sm β 32px // Small buttons
size-md β 40px // Default buttons
size-lg β 48px // Large buttons
size-xl β 64px // Hero elements
The 10 Base Primitives
1. Container
Purpose: Control page width and alignment
Location: /components/ui/Container.tsx
Usage:
<Container size="lg">
<WardDashboard />
</Container>
Variants:
sm β Reading pages (640px max-width)
md β Dashboards (768px max-width)
lg β Maps/data-heavy views (1024px max-width)
xl β Full-width (no max-width)
Props:
interface ContainerProps {
size?: 'sm' | 'md' | 'lg' | 'xl'
children: React.ReactNode
className?: string
}
Example:
function WardPage({ ward }: { ward: Ward }) {
return (
<Container size="lg">
<WardHeader ward={ward} />
<WardStats ward={ward} />
<WardFeed wardCode={ward.code} />
</Container>
);
}
2. Text
Purpose: Centralized typography with semantic variants
Location: /components/ui/Text.tsx
Usage:
<Text variant="heading">Ward 23</Text>
<Text variant="body">Crime statistics for this week</Text>
<Text variant="muted">Last updated 2 hours ago</Text>
Variants:
heading β Page titles (2xl, bold)
subheading β Section titles (xl, semibold)
body β Regular text (base, normal)
muted β Secondary information (base, gray)
label β Form labels (sm, medium)
caption β Small helper text (xs, normal)
danger β Error messages (base, red)
Props:
interface TextProps {
variant?: 'heading' | 'subheading' | 'body' | 'muted' | 'label' | 'caption' | 'danger'
children: React.ReactNode
className?: string
as?: 'p' | 'span' | 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
}
Rules:
- β
Use Text component instead of raw
<p> or <span>
- β
Choose variant based on semantic meaning, not appearance
- β
Override styles sparingly with className
Example:
function SignalCard({ signal }: { signal: Signal }) {
return (
<Card>
<Text variant="subheading">{signal.title}</Text>
<Text variant="body">{signal.description}</Text>
<Text variant="muted">
Reported {formatDistanceToNow(signal.createdAt)}
</Text>
</Card>
);
}
3. Badge
Purpose: Status indicators and labels
Location: /components/ui/Badge.tsx
Usage:
<Badge variant="warning">High Risk</Badge>
<Badge variant="success">Verified</Badge>
Variants:
neutral β Default/informational (gray)
success β Positive/confirmed (green)
warning β Caution/attention needed (amber)
danger β Critical/error (red)
info β Informational (blue)
Props:
interface BadgeProps {
variant?: 'neutral' | 'success' | 'warning' | 'danger' | 'info'
children: React.ReactNode
className?: string
}
Example:
function WardStatus({ ward }: { ward: Ward }) {
return (
<div className="flex items-center gap-2">
<Text variant="body">{ward.name}</Text>
{ward.riskLevel === 'high' && (
<Badge variant="danger">High Risk</Badge>
)}
{ward.verified && (
<Badge variant="success">Verified</Badge>
)}
</div>
);
}
Purpose: Interactive actions and CTAs
Location: /components/ui/Button.tsx
Usage:
<Button variant="primary">Save Changes</Button>
<Button variant="outline">Cancel</Button>
<Button variant="ghost" size="sm">Edit</Button>
Variants:
primary β Main actions (solid, primary color)
secondary β Secondary actions (solid, gray)
outline β Alternative actions (bordered)
ghost β Subtle actions (minimal styling)
danger β Destructive actions (red)
Sizes:
sm β Small (32px height)
md β Medium (40px height, default)
lg β Large (48px height)
icon β Icon-only (square, 40px)
States:
disabled β Non-interactive
loading β Show spinner
Props:
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger'
size?: 'sm' | 'md' | 'lg' | 'icon'
children?: React.ReactNode
disabled?: boolean
loading?: boolean
onClick?: () => void
type?: 'button' | 'submit' | 'reset'
className?: string
}
Rules:
- β
Button never fetches data directly
- β
Button never owns business logic
- β
Use
type="submit" for form submissions
- β
Show loading state for async actions
Example:
function JoinWardButton({ wardCode }: { wardCode: string }) {
const [isLoading, setIsLoading] = useState(false);
const handleJoin = async () => {
setIsLoading(true);
await joinWard(wardCode);
setIsLoading(false);
};
return (
<Button
variant="primary"
loading={isLoading}
onClick={handleJoin}
>
Join Ward
</Button>
);
}
5. Card
Purpose: Visual grouping and content containers
Location: /components/ui/Card.tsx
Usage:
<Card>
<CardHeader>
<Text variant="subheading">Ward Statistics</Text>
</CardHeader>
<CardContent>
<WardStats stats={stats} />
</CardContent>
</Card>
Composition:
Card β Container
CardHeader β Title section
CardContent β Main content
CardFooter β Actions/footer
Props:
interface CardProps {
children: React.ReactNode
className?: string
}
interface CardHeaderProps {
children: React.ReactNode
className?: string
}
interface CardContentProps {
children: React.ReactNode
className?: string
}
interface CardFooterProps {
children: React.ReactNode
className?: string
}
Rules:
- β
Card never fetches data
- β
Card never assumes content structure
- β
Use semantic sections (Header, Content, Footer)
Example:
function WeatherCard({ weather }: { weather: WeatherData }) {
return (
<Card>
<CardHeader>
<Text variant="subheading">Current Weather</Text>
</CardHeader>
<CardContent>
<div className="flex items-center gap-4">
<WeatherIcon condition={weather.condition} />
<div>
<Text variant="heading">{weather.temperature}Β°C</Text>
<Text variant="muted">{weather.condition}</Text>
</div>
</div>
</CardContent>
<CardFooter>
<Text variant="caption">
Updated {formatDistanceToNow(weather.updatedAt)}
</Text>
</CardFooter>
</Card>
);
}
Purpose: Text input fields with validation
Location: /components/ui/Input.tsx
Usage:
<Input
label="Ward Name"
placeholder="Enter ward name"
value={name}
onChange={(e) => setName(e.target.value)}
error={errors.name}
/>
Variants:
text β Default text input
number β Numeric input
search β Search field
email β Email input
password β Password input
States:
disabled β Non-interactive
error β Validation error
required β Required field
Props:
interface InputProps {
label: string
type?: 'text' | 'number' | 'search' | 'email' | 'password'
placeholder?: string
value?: string
defaultValue?: string
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
disabled?: boolean
required?: boolean
error?: string
className?: string
name?: string
id?: string
}
Rules:
- β
Always provide a label (accessibility)
- β
Show error messages below input
- β
Use
required prop for form validation
- β
Associate label with input using
id and htmlFor
Example:
function WardSearch() {
const [query, setQuery] = useState('');
const [error, setError] = useState('');
return (
<Input
label="Search Wards"
type="search"
placeholder="Enter ward code or name"
value={query}
onChange={(e) => setQuery(e.target.value)}
error={error}
/>
);
}
7. Select
Purpose: Dropdown selection with accessibility
Location: /components/ui/Select.tsx
Usage:
<Select
label="Municipality"
options={municipalities}
value={selectedMunicipality}
onValueChange={setSelectedMunicipality}
placeholder="Select a municipality"
/>
Props:
interface SelectOption {
value: string
label: string
disabled?: boolean
}
interface SelectProps {
label: string
options: SelectOption[]
value?: string
defaultValue?: string
onValueChange?: (value: string) => void
placeholder?: string
disabled?: boolean
required?: boolean
className?: string
}
Built on:
- Radix UI Select (keyboard accessible)
- Proper ARIA attributes
- Screen reader friendly
Example:
function MunicipalityFilter({ municipalities }: { municipalities: Municipality[] }) {
const [selected, setSelected] = useState('');
const options = municipalities.map(m => ({
value: m.code,
label: m.name
}));
return (
<Select
label="Filter by Municipality"
options={options}
value={selected}
onValueChange={setSelected}
placeholder="All municipalities"
/>
);
}
8. Stack / Flex
Purpose: Consistent spacing between elements
Location: /components/ui/Stack.tsx and /components/ui/Flex.tsx
Usage:
// Vertical stack
<Stack gap="md">
<Text variant="heading">Title</Text>
<Text variant="body">Content</Text>
<Button>Action</Button>
</Stack>
// Horizontal flex
<Flex gap="sm" align="center">
<Badge variant="info">Status</Badge>
<Text variant="body">Details</Text>
</Flex>
Stack Props:
interface StackProps {
gap?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
children: React.ReactNode
className?: string
}
Flex Props:
interface FlexProps {
gap?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
direction?: 'row' | 'column'
align?: 'start' | 'center' | 'end' | 'stretch'
justify?: 'start' | 'center' | 'end' | 'between' | 'around'
children: React.ReactNode
className?: string
}
Benefits:
- β
Prevents random margins
- β
Enforces consistent spacing
- β
Reduces CSS complexity
Example:
function SignalCard({ signal }: { signal: Signal }) {
return (
<Card>
<CardContent>
<Stack gap="sm">
<Flex justify="between" align="center">
<Text variant="subheading">{signal.title}</Text>
<Badge variant={signal.severity}>
{signal.severity}
</Badge>
</Flex>
<Text variant="body">{signal.description}</Text>
<Text variant="muted">
{formatDistanceToNow(signal.createdAt)}
</Text>
</Stack>
</CardContent>
</Card>
);
}
9. Alert
Purpose: Important messages and notifications
Location: /components/ui/Alert.tsx
Usage:
<Alert variant="danger">
This ward has increased crime incidents this week.
</Alert>
<Alert variant="info" title="Heads Up">
Weather advisory issued for your area.
</Alert>
Variants:
info β Informational (blue)
success β Positive confirmation (green)
warning β Caution (amber)
danger β Critical alert (red)
Props:
interface AlertProps {
variant?: 'info' | 'success' | 'warning' | 'danger'
title?: string
children: React.ReactNode
className?: string
icon?: React.ReactNode
}
Rules:
- β
Use for important, attention-requiring messages
- β
Keep text concise and actionable
- β
Include title for complex alerts
Example:
function WardAlert({ ward }: { ward: Ward }) {
if (ward.riskLevel === 'high') {
return (
<Alert variant="danger" title="High Risk Area">
<Stack gap="xs">
<Text variant="body">
This ward has reported multiple incidents in the past 7 days.
</Text>
<Button variant="outline" size="sm">
View Details
</Button>
</Stack>
</Alert>
);
}
return null;
}
10. Separator
Purpose: Visual separation between sections
Location: /components/ui/Separator.tsx
Usage:
<Stack gap="md">
<Section1 />
<Separator />
<Section2 />
</Stack>
Props:
interface SeparatorProps {
orientation?: 'horizontal' | 'vertical'
className?: string
}
Rules:
- β
Use for clarity, not decoration
- β
Prefer semantic grouping (Card, Stack) over separators
- β
Use sparingly to avoid visual noise
Component Composition
Building Domain Components
Domain components compose primitives to create Visita-specific features.
Example: Ward Card
function WardCard({ ward }: { ward: Ward }) {
return (
<Card>
<CardHeader>
<Flex justify="between" align="center">
<Stack gap="xs">
<Text variant="subheading">{ward.name}</Text>
<Text variant="muted">Ward {ward.code}</Text>
</Stack>
<Badge variant={ward.riskLevel}>
{ward.riskLevel}
</Badge>
</Flex>
</CardHeader>
<CardContent>
<Stack gap="md">
<WardStats stats={ward.stats} />
<Text variant="body">{ward.description}</Text>
</Stack>
</CardContent>
<CardFooter>
<Flex gap="sm">
<Button variant="primary" size="sm">
View Details
</Button>
<Button variant="outline" size="sm">
Join Ward
</Button>
</Flex>
</CardFooter>
</Card>
);
}
Composition Rules
- Primitives First β Use primitives before custom elements
- Semantic Structure β Card > Header/Content/Footer
- Consistent Spacing β Stack/Flex for layout
- Clear Hierarchy β Text variants for typography
- Accessible by Default β All primitives include a11y
Accessibility Requirements
Every Primitive Must Support:
- β
Keyboard Navigation β Full keyboard operability
- β
Focus Management β Visible focus indicators
- β
Screen Readers β ARIA labels and descriptions
- β
Color Contrast β 4.5:1 minimum ratio
- β
High Contrast Mode β Respect system preferences
Testing Checklist
Before shipping a primitive:
- Lightweight β Minimal bundle size
- No Side Effects β Pure rendering
- Memoization β Use React.memo when appropriate
- Tree Shakeable β ES6 module exports
Usage Patterns
// β
GOOD: Direct imports
import { Button } from '@/components/ui/Button'
import { Card, CardContent } from '@/components/ui/Card'
// β BAD: Barrel imports (prevents tree-shaking)
import { Button, Card } from '@/components/ui'
Summary
Next Steps
Status: Active
Applies To: All UI component development
Reviewed: January 2026