Introduction
The UpForService Design System is the single source of truth for the product's visual language. It provides designers and engineers with shared tokens, patterns, and guidelines to build consistent, accessible interfaces at scale.
Tokens first
Every color, size, and shadow is a named token. No magic values in components.
Composable
Primitives compose into patterns. Patterns compose into pages.
Accessible
WCAG AA contrast, keyboard navigation, and semantic HTML throughout.
Design Principles
Four principles guide every decision in the UpForService design system.
Trust through clarity
Financial transactions and service contracts require unambiguous communication. Every interface element must be clear about what it does and what will happen next.
Simple in UI, rich in domain
The UI exposes the happy path. The domain model carries versioning, event history, and state machines from day one. Complexity lives in the right layer.
Accessible by default
Contrast, keyboard navigation, focus rings, and semantic HTML are not optional features. They are baseline requirements for every component.
Internationalisation ready
Every string is a translation key. Layouts account for text expansion. Currency and number formats follow locale conventions.
Colors
All colors are defined as CSS custom properties in globals.css and mapped to Tailwind via @theme inline. The palette supports light and dark themes.
Brand
Primary #0A6FFF: 3.0:1 on white (AA-Large) · use --primary-text for body textPrimary
--primary
#0A6FFF
Primary hover
--primary-hover
#0858D4
Primary text
--primary-text
#0858D4
Tertiary
--tertiary
#7C3AED
Background & Surface
Background
--background
#FFFFFF
Pale blue
--surface-pale-blue
#F8FAFF
Tint blue
--surface-tint-blue
#F0F5FF
Cool
--surface-cool
#F8FAFC
Navy
--surface-navy
#0A1628
Navy mid
--surface-navy-mid
#0D1F3C
Text
All text tokens: 4.5:1+ AA on white backgroundHeading
--heading
#111827
Foreground
--foreground
#1F2937
Text secondary
--text-secondary
#4B5563
Muted
--muted-foreground
#6B7280
Quiet
--quiet
#9CA3AF
UI Chrome
Card
--card
#FFFFFF
Surface secondary
--secondary
#F8FAFC
Accent
--accent
#F0F5FF
Border
--border
#E5E7EB
Input
--input
#E5E7EB
Muted surface
--muted
#F8FAFF
Status
All status foreground tokens meet 4.5:1 AA on their respective bg tokensDestructive
--destructive
#EF4444
Warning
--warning
#D97706
Success
--success
#059669
Info
--info
#2563EB
Warning bg
--warning-bg
#FFFBEB
Success bg
--success-bg
#ECFDF5
Disabled
Disabled
--disabled
#E5E7EB
Disabled fg
--disabled-foreground
#9CA3AF
Dark mode
All tokens have dark overrides in the .dark class in globals.css. The dark theme uses the same navy palette as the navy marketing sections, maintaining brand consistency across modes.
Typography
Three font families are loaded via Next.js font optimization. Bricolage Grotesque for display headings, Inter for body and UI, and JetBrains Mono for code and monetary values.
Font families
Display
Aa Bb Cc
Bricolage Grotesque
--font-display
Headings, marketing
Sans
Aa Bb Cc
Inter
--font-sans
Body, UI labels
Mono
Aa Bb Cc
JetBrains Mono
--font-mono
Code, reference IDs
Type scale
Display XL
48–72px · 800 · display
Display LG
36–48px · 800 · display
Display MD
30–36px · 700 · display
H1
36px · 700 · display
H2
30px · 700 · display
H3
24px · 600 · display
H4
20px · 600 · sans
Body large
18px · 400 · sans
Body
16px · 400 · sans
Body small
14px · 400 · sans
Caption
12px · 400 · sans
Label
14px · 500 · sans
Mono
14px · 400 · mono
Spacing
4px base grid. All spacing values are multiples of 4px. Use Tailwind's spacing scale directly — p-4 = 16px, gap-6 = 24px.
Border & Radius
Radius tokens are defined as CSS custom properties and mapped to Tailwind via @theme inline. Use rounded-pill, rounded-card, rounded-md, rounded-sm, rounded-xs.
None
--radius-none
0px
xs
--radius-xs
6px
sm
--radius-sm
10px
md
--radius-md
16px
Card / lg
--radius-card
24px
Pill / full
--radius-pill
9999px
Border widths
border
1px
border-2
2px
border-4
4px
Shadows
Shadows are named semantically. Use shadow-raised for elevated UI elements, shadow-feature-panel for cards in marketing sections, and shadow-cta-glow for primary action buttons.
raised
--shadow-raised
feature-panel
--shadow-feature-panel
marketing
--shadow-marketing
cta-glow
--shadow-cta-glow
Focus ring
All interactive elements use --ring for focus visibility. Applied via focus-visible:ring-[3px] focus-visible:ring-ring/50.
Z-index scale
Defined in the tokens JSON. Use these values to maintain consistent stacking order across the application.
Breakpoints & Grid
Mobile-first responsive design. Tailwind's default breakpoint scale with a max-w-[1200px] content container and max-w-[1400px] for full-bleed navigation.
Container & gutters
Content max-width
1200px
Pages, sections
Nav max-width
1400px
Top nav, footer
Gutter (mobile)
px-4 (16px)
All viewports
Gutter (md+)
px-6 (24px)
Tablet and up
Grid columns
12
CSS Grid base
Components
Built on shadcn/ui primitives with Radix UI. All components use class-variance-authority for variants and accept a className prop for extension.
Badge
Inline label for status, categories, and metadata. Uses rounded-full and supports asChild.
Variants
Status examples
import { Badge } from '@/components/ui/badge'
<Badge>Active</Badge>
<Badge variant="destructive">Failed</Badge>
<Badge variant="outline">Pending</Badge>
<Badge variant="secondary">Draft</Badge>
// Status with icon
<Badge className="bg-success text-success-foreground">
<CheckCircle2 className="size-3" />
Confirmed
</Badge>
// Eyebrow — purely typographic, no visual container
<Badge variant="eyebrow">Service category</Badge>Card
Container for grouped content. Uses rounded-card (24px). Sub-components: CardHeader, CardTitle, CardDescription, CardAction, CardContent, CardFooter.
Default
Detailed review of your React app's render cycles, bundle size, and Core Web Vitals.
Feature (elevated)
Funds are released only after you confirm the service was delivered as agreed.
import { Card, CardHeader, CardTitle, CardDescription,
CardAction, CardContent, CardFooter } from '@/components/ui/card'
// Default
<Card>
<CardHeader>
<CardTitle>Service offer</CardTitle>
<CardDescription>React performance audit</CardDescription>
<CardAction><Badge>Active</Badge></CardAction>
</CardHeader>
<CardContent>Description goes here.</CardContent>
<CardFooter><Button>View details</Button></CardFooter>
</Card>
// Feature (elevated shadow)
<Card variant="feature">...</Card>Avatar
User representation with image, initials fallback, and badge support. Built on Radix UI Avatar primitive.
Sizes
Avatar group
Deterministic color backgrounds
8 backgrounds at OKLCH L=0.45 C=0.10 — all pass 4.5:1 WCAG AA against white initials.
import { Avatar, AvatarImage, AvatarFallback,
AvatarGroup, AvatarGroupCount } from '@/components/ui/avatar'
// With image + fallback
<Avatar size="lg">
<AvatarImage src="/photo.jpg" alt="Lucy Brandão" />
<AvatarFallback
style={{ backgroundColor: 'var(--avatar-bg-3)', color: 'var(--avatar-fg)' }}>
LB
</AvatarFallback>
</Avatar>
// Group
<AvatarGroup>
<Avatar><AvatarFallback>AB</AvatarFallback></Avatar>
<Avatar><AvatarFallback>CD</AvatarFallback></Avatar>
<AvatarGroupCount>+5</AvatarGroupCount>
</AvatarGroup>Text field
Single-line text input. Shares focus, error, and disabled tokens consistently across all form controls.
Default
With helper text
Visible on your public profile page.
Error state
Enter a valid email address.
Disabled
Address is locked after verification.
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
// Default
<div className="space-y-2">
<Label htmlFor="title">Service title</Label>
<Input id="title" placeholder="React performance audit" />
</div>
// Error state
<div className="space-y-2">
<Label htmlFor="email" className="text-destructive">Email</Label>
<Input id="email" aria-invalid="true"
className="border-destructive focus-visible:ring-destructive/20" />
<p className="text-xs text-destructive" role="alert">
Enter a valid email address.
</p>
</div>
// Disabled
<Input disabled value="locked@value.com" />Label (via htmlFor) or an aria-label. Error messages use aria-invalid="true".Select
Dropdown selection built on Radix UI Select. Keyboard navigable, supports groups, labels, and disabled items.
Default
With groups
Error state
Enter a valid email address.
Disabled
import { Select, SelectTrigger, SelectValue, SelectContent,
SelectItem, SelectGroup, SelectLabel, SelectSeparator
} from '@/components/ui/select'
<Select>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select category" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Technology</SelectLabel>
<SelectItem value="web">Web development</SelectItem>
<SelectItem value="mobile">Mobile apps</SelectItem>
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Creative</SelectLabel>
<SelectItem value="design">Graphic design</SelectItem>
</SelectGroup>
</SelectContent>
</Select>SelectLabel and SelectSeparator for grouped options. Disabled items use data-[disabled].Textarea
Multi-line text input. Uses field-sizing-content for automatic height growth. Shares the same focus, error, and disabled tokens as Text field.
Default
Maximum 2 000 characters.
With helper text
Your review will be public on the provider's profile.
Error state
Bio must be at least 20 characters.
Disabled
Internal notes are only visible to you.
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
<div className="space-y-2">
<Label htmlFor="desc">Service description</Label>
<Textarea id="desc"
placeholder="Describe what's included…"
/>
<p className="text-xs text-muted-foreground">
Maximum 2 000 characters.
</p>
</div>Label. Error state uses aria-invalid="true". Auto-resize via field-sizing-content requires no JavaScript.Separator
Thin visual divider. Horizontal by default; use orientation="vertical" for inline contexts.
Horizontal
Section A
Section B
Section C
Vertical
import { Separator } from '@/components/ui/separator'
// Horizontal (default) — between sections
<Separator />
// Vertical — between inline items
<div className="flex items-center gap-4 h-8">
<span>Overview</span>
<Separator orientation="vertical" />
<span>Reviews</span>
</div>
// Semantic (not decorative)
<Separator decorative={false} aria-label="Between navigation regions" />decorative={false} and an aria-label when the separator conveys structure (e.g. between navigation regions).Skeleton
Animated placeholder shown while content is loading. Use className to match the shape of the real content.
Text lines
Card
Avatar + text
import { Skeleton } from '@/components/ui/skeleton'
// Text lines
<div className="space-y-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
</div>
// Avatar + text
<div className="flex items-center gap-3">
<Skeleton className="size-10 rounded-full" />
<div className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-20" />
</div>
</div>
// Wrap loading region for screen readers
<div aria-busy="true" aria-label="Loading offers">
<Skeleton className="h-48 w-full rounded-card" />
</div>aria-busy="true" and remove it once the real content appears. Skeleton itself carries no ARIA role.Checkbox
Binary input for boolean selections. Built on Radix UI Checkbox — fully keyboard accessible.
States
import { Checkbox } from '@/components/ui/checkbox'
import { Label } from '@/components/ui/label'
// Default
<div className="flex items-center gap-2">
<Checkbox id="terms" />
<Label htmlFor="terms">Accept terms of service</Label>
</div>
// Controlled
const [checked, setChecked] = useState(false)
<Checkbox checked={checked} onCheckedChange={setChecked} />
// Indeterminate (e.g. select-all with partial selection)
<Checkbox checked="indeterminate" />Label via htmlFor. The indeterminate state uses checked="indeterminate" and requires a programmatic trigger — it is not a native HTML state.Switch
Toggle for on/off states. Prefer Switch over Checkbox when the action takes effect immediately (no form submit needed).
Default (sm + default sizes)
States
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
// Uncontrolled
<div className="flex items-center gap-2">
<Switch id="notifications" />
<Label htmlFor="notifications">Email notifications</Label>
</div>
// Controlled
const [on, setOn] = useState(false)
<Switch checked={on} onCheckedChange={setOn} />
// Small size
<Switch size="sm" />Label. The Switch communicates its state via aria-checked — no extra ARIA needed.Tabs
Organises related content into selectable panels. Supports two visual variants: default (pill) and line.
Default (pill)
Full service description, inclusions, process, and estimated timeline appear here.
Line
Full service description, inclusions, process, and estimated timeline appear here.
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
// Default (pill) variant
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="reviews">Reviews</TabsTrigger>
</TabsList>
<TabsContent value="overview">Content A</TabsContent>
<TabsContent value="reviews">Content B</TabsContent>
</Tabs>
// Line variant
<TabsList variant="line">
<TabsTrigger value="overview">Overview</TabsTrigger>
</TabsList>aria-controls / aria-labelledby by Radix.Tooltip
Non-interactive contextual label triggered on hover/focus. Wrap interactive elements in TooltipProvider at the root of the tree.
Default
import { Tooltip, TooltipTrigger, TooltipContent,
TooltipProvider } from '@/components/ui/tooltip'
// Wrap the tree once (typically in a layout)
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Escrow info">
<ShieldCheck className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
Funds held in escrow until delivery is confirmed
</TooltipContent>
</Tooltip>
</TooltipProvider>TooltipProvider delayDuration={0} to remove open delay.Dialog
Modal overlay that focuses attention on a critical decision or form. Built on Radix UI Dialog — traps focus, closes on Escape.
Default
Destructive action
import { Dialog, DialogTrigger, DialogContent, DialogHeader,
DialogTitle, DialogDescription, DialogFooter
} from '@/components/ui/dialog'
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">Open dialog</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Confirm order</DialogTitle>
<DialogDescription>
Payment will be held in escrow until delivery.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline">Cancel</Button>
<Button>Place order</Button>
</DialogFooter>
</DialogContent>
</Dialog>DialogTitle and DialogDescription are required — they provide the accessible name and description consumed by aria-labelledby / aria-describedby on the dialog container.Callout
Inline contextual message with icon. Used to surface trust cues, status notes, and non-blocking alerts without interrupting the user's flow.
Info (default)
Success
Warning
Danger
Neutral
import { Callout } from '@/components/ui/callout'
import { ShieldCheck, AlertTriangle } from 'lucide-react'
// Info (default) — escrow trust cue
<Callout icon={<ShieldCheck />}>
Your payment is protected by escrow until delivery.
</Callout>
// Warning
<Callout variant="warning" icon={<AlertTriangle />}>
This offer will be paused after 30 days of inactivity.
</Callout>
// Critical — add role="alert" for screen readers
<Callout variant="danger" icon={<AlertCircle />} role="alert">
Payment failed. Please try again.
</Callout>role="alert" element so screen readers announce it immediately.Pagination
Offset-based pagination with truncation for large page counts. Page 1 omits the page query param. The live component uses useSearchParams for URL-driven state — import from @/components/public/pagination.
Default (page 2 of 8)
First page (prev disabled)
Last page (next disabled)
import { Pagination } from '@/components/public/pagination'
// URL-driven — reads/writes ?page= search param
<Suspense>
<Pagination total={180} pageSize={20} page={currentPage} />
</Suspense>Design System v1
This is a living document. New components and tokens will be added as the product evolves. Download the tokens to use them in Figma or other tools.