Skip to main content

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

4. Button

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

6. Input

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

  1. Primitives First β€” Use primitives before custom elements
  2. Semantic Structure β€” Card > Header/Content/Footer
  3. Consistent Spacing β€” Stack/Flex for layout
  4. Clear Hierarchy β€” Text variants for typography
  5. 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:
  • Test with keyboard only (Tab, Enter, Escape, Arrows)
  • Test with screen reader (NVDA, VoiceOver, JAWS)
  • Verify color contrast ratios
  • Check focus states and traps
  • Test with browser zoom (200%)
  • Validate ARIA attributes

Performance Guidelines

Primitive Performance

  1. Lightweight β€” Minimal bundle size
  2. No Side Effects β€” Pure rendering
  3. Memoization β€” Use React.memo when appropriate
  4. 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

10 Base Primitives

Design Tokens

Accessibility First

Composable


Next Steps


Status: Active
Applies To: All UI component development
Reviewed: January 2026