Button
Primary actions use ink. Tertiary uses chartreuse for celebratory or high-attention actions (e.g. "Place bid"). Ghost is for secondary inline actions in dense layouts.
| Prop | Type | Default |
|---|---|---|
variant | 'primary' | 'secondary' | 'tertiary' | 'destructive' | 'ghost' | 'primary' |
size | 'sm' | 'md' | 'lg' | 'md' |
loading | boolean | false |
disabled | boolean | false |
leftIcon · rightIcon | ReactNode | — |
IconButton
Same variant ladder as Button but icon-only — square by
default, round for floating actions and avatar-adjacent controls.
Always include an aria-label.
| Prop | Type | Default |
|---|---|---|
icon | ReactNode (required) | — |
aria-label | string (required) | — |
variant | 'primary' | 'secondary' | 'tertiary' | 'destructive' | 'ghost' | 'secondary' |
size | 'sm' | 'md' | 'lg' | 'xl' | 'md' |
shape | 'square' | 'round' | 'square' |
loading | boolean | false |
disabled | boolean | false |
elevated | boolean — adds FAB-style shadow | false |
Input
Textarea
SearchBar
The primary discovery surface. Always pill-shaped (matches
tokens.ts → cornerRadius.chip). Use the filled variant
inside cards / sheets where an outlined border would compete with the
parent surface.
| Prop | Type | Default |
|---|---|---|
value · onChangeText | string · (text) => void | — |
placeholder | string | 'Search' |
variant | 'outlined' | 'filled' | 'outlined' |
size | 'sm' | 'md' | 'lg' | 'md' |
showClear | boolean — auto-shown when value is non-empty | true |
onVoicePress | () => void — renders mic action when provided | — |
onFilterPress · activeFilterCount | () => void · number | — |
suggestions | SearchSuggestion[] — opens dropdown when non-empty + focused | [] |
loading · disabled | boolean | false |
Checkbox · Radio · Toggle
Select · Picker
Card · ElevatedCard
Flat Card for in-flow content. ElevatedCard for items that need to float — featured jobs, summary tiles.
Avatar
StatusPill
Tag · Chip
Modal · BottomSheet · Toast
Accept this bid?
Accepting Jan de Vries' bid of €85/hr will close this job to other bidders and start a Deal with chat.
Filter jobs
EmptyState · ErrorState · LoadingState · SkeletonLoader
Signature components
These appear on most screens and carry the Soft Pro feel. Each lists its TypeScript props interface, the component skeleton, and at least two variants rendered in both light and dark mode for visual review.
UrgencyTimer
The most visible recurring element in the app — sits on every JobCard
and the JobDetail header. Reaches into the urgency color tokens
(color.urgency.*) as it shifts state every second.
// UrgencyTimer.tsx export type UrgencyState = 'ample' | 'halfway' | 'near-expiry' | 'expired'; export interface UrgencyTimerProps { /** Job posted-at timestamp (ms). */ startAt: number; /** Bid window length in ms — 8h / 16h / 24h / 48h. */ windowMs: number; size?: 'sm' | 'md' | 'lg'; hideIcon?: boolean; /** Fires when the timer crosses into a new state. */ onStateChange?: (state: UrgencyState) => void; }
import { useEffect, useState } from 'react'; import { View, Text } from 'react-native'; import { useTheme } from '@/design-system/theme'; const resolveState = (pct: number): UrgencyState => pct >= 1 ? 'expired' : pct >= 0.8 ? 'near-expiry' : pct >= 0.5 ? 'halfway' : 'ample'; export const UrgencyTimer = ({ startAt, windowMs, size = 'md', onStateChange }: UrgencyTimerProps) => { const t = useTheme(); const [now, setNow] = useState(() => Date.now()); useEffect(() => { const id = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(id); }, []); const remaining = Math.max(0, windowMs - (now - startAt)); const state = resolveState((now - startAt) / windowMs); const tone = { ample: { bg: t.color.feedback.success.muted, fg: t.color.feedback.success.default }, halfway: { bg: t.color.feedback.warning.muted, fg: t.color.feedback.warning.default }, 'near-expiry': { bg: t.color.feedback.error.muted, fg: t.color.feedback.error.default }, expired: { bg: t.color.surface.secondary, fg: t.color.text.muted }, }[state]; return ( <View style={{ backgroundColor: tone.bg, borderRadius: t.cornerRadius.chip, paddingHorizontal: t.space.md }}> <Text style={{ color: tone.fg, ...t.textStyle.captionEmphasis }}>{formatRemaining(remaining)}</Text> </View> ); };
FsBadge
Surfaces a provider's For Suppliers validation status across cards, profile headers, and bid lists. Reads as trust signal first, link second — never as a CTA on its own.
// FsBadge.tsx export type FsState = 'verified' | 'pending' | 'not-linked'; export interface FsBadgeProps { state: FsState; size?: 'sm' | 'md'; /** Tapping opens the FS validation explainer sheet. */ onPress?: () => void; }
SectorPill
Atomic representation of a job's trade. Lives inside JobCard, the
SectorChips filter, ProviderProfile specialties, and the Path3
registration wizard. Icons are Tabler outline (24×24).
Two variants, both routed through tokens in tokens.ts:
neutral (surface.secondary + text.primary) —
quiet, used in dense lists; and accent
(brand.accent + text.onAccent) —
chartreuse-on-ink, used to call attention to a featured or
recommended sector. Icon stroke bumps to 2.5 in the accent variant
for prominence against the high-chroma background. Light + dark
flips happen automatically through the theme tokens; the accent
variant is intentionally non-inverting because the chartreuse-on-ink
combo IS the brand.
// SectorPill.tsx — one neutral pill style for every sector. // Only the icon and label vary; colors come from theme tokens and // flip automatically between light + dark. import { IconDroplet, IconBolt, IconHammer, IconBrush, IconGrid4x4, IconHome2, IconAirConditioning, IconLock, IconTools, IconTool, } from '@tabler/icons-react-native'; import { useTheme } from '../theme'; export type SectorKey = | 'plumber' | 'electrician' | 'carpenter' | 'painter' | 'tiling' | 'roofing' | 'hvac' | 'locks' | 'handyman' | 'repairs'; export interface SectorPillProps { sector: SectorKey; /** Override label (default: SECTOR_META[sector].labelNL). */ label?: string; size?: 'sm' | 'md' | 'lg'; /** 'neutral' = surface.secondary + text.primary (default). * 'accent' = brand.accent + text.onAccent (chartreuse — non-inverting). */ variant?: 'neutral' | 'accent'; onPress?: () => void; } // Per-sector data: icon glyph + Dutch label. No colors here — // background and foreground are uniform across all sectors. export const SECTOR_META: Record<SectorKey, { Icon: TablerIcon; labelNL: string }> = { plumber: { Icon: IconDroplet, labelNL: 'Loodgieter' }, electrician: { Icon: IconBolt, labelNL: 'Elektricien' }, carpenter: { Icon: IconHammer, labelNL: 'Timmerman' }, painter: { Icon: IconBrush, labelNL: 'Schilder' }, tiling: { Icon: IconGrid4x4, labelNL: 'Tegels / Badkamer' }, roofing: { Icon: IconHome2, labelNL: 'Dak / Goten' }, hvac: { Icon: IconAirConditioning, labelNL: 'HVAC / Installatie' }, locks: { Icon: IconLock, labelNL: 'Sloten / Beveiliging' }, handyman: { Icon: IconTools, labelNL: 'Klusjesman' }, repairs: { Icon: IconTool, labelNL: 'Kleine reparaties' }, }; export const SectorPill = ({ sector, label, size = 'md', variant = 'neutral', onPress }: SectorPillProps) => { const { color } = useTheme(); const { Icon, labelNL } = SECTOR_META[sector]; // Neutral: surface.secondary + text.primary (auto-flips light/dark). // Accent: brand.accent + text.onAccent (chartreuse — non-inverting, // same hex in both themes; icon thickens to stroke 2.5 for prominence). const bg = variant === 'accent' ? color.brand.accent : color.surface.secondary; const fg = variant === 'accent' ? color.text.onAccent : color.text.primary; const stroke = variant === 'accent' ? 2.5 : 2; // ...render Pressable with bg + <Icon color={fg} strokeWidth={stroke}/> + <Text>{label ?? labelNL}</Text> };
BoostFlame
Sits on JobCards that the client paid to boost. The flame flickers gently — enough to be noticed, not enough to distract. Pairs with the remaining-days countdown when shown on the seller's own job list.
// BoostFlame.tsx export interface BoostFlameProps { size?: 'sm' | 'md' | 'lg'; /** When provided, renders "BOOST · {n} DAYS LEFT". */ daysLeft?: number; /** Disable the flicker (e.g. on reduced motion). Default: true. */ animated?: boolean; }
JobCard
The hero of the Feed tab. Media (photo or video thumbnail) covers the
top half as a background. UrgencyTimer pins to the top-left;
BoostFlame to the top-right; voice-note play button sits bottom-left
when the job has an attached voice note. The sector slot below the
title renders a SectorPill — neutral by
default, switched to accent for boosted / featured /
private-pool-eligible jobs so the trade reads at a glance.
Watermark variant — a 64×64 chartreuse500 icon at 30% opacity, pinned to the bottom-right of the card body, sitting just above the footer divider. The watermark mirrors the card's SectorPill icon at large scale, so the trade reads twice — small chartreuse pill up top, oversized chartreuse echo down low. Paired with the accent SectorPill, this reads as a "Spoedmarkt-featured" treatment. Same hex in light and dark (chartreuse is non-inverting). Decorative only — body content paints over it.
// JobCard.tsx export interface JobCardProps { job: { id: string; title: string; sector: { label: string; icon: React.ReactNode }; media?: { type: 'photo' | 'video'; url: string; thumbnail?: string }; voiceNote?: { url: string; durationSec: number }; location: string; distanceKm: number; price: { type: 'fixed' | 'hourly' | 'range' | 'let-bid'; min?: number; max?: number; }; bidCount: number; urgency: { startAt: number; windowMs: number }; boosted?: { daysLeft: number }; }; onPress: () => void; onVoicePlay?: () => void; }
BidCard
One per bid on the JobDetail screen. The bid amount is the visual anchor — it gets the chartreuse-tinted surface treatment. Verification chips communicate trust at a glance; the Accept action carries the ink primary style for emphasis.
// BidCard.tsx export interface BidCardProps { bid: { id: string; status: 'pending' | 'accepted' | 'declined' | 'withdrawn'; provider: { id: string; name: string; avatarUrl?: string; rating: number; // 0–5 reviewCount: number; verifications: { id: boolean; kvk: boolean; iban: boolean }; fsValidated?: boolean; }; amount: { type: 'fixed' | 'hourly'; value: number; note?: string }; message?: string; placedAt: number; }; onAccept: () => void; onDecline: () => void; loading?: 'accept' | 'decline' | null; }
MediaCarousel
Horizontal scroller for the attachments on a job. Voice notes get the chartreuse waveform treatment so they read as distinct from photos at a glance. Tapping a tile opens the full-screen media viewer.
// MediaCarousel.tsx export type MediaItem = | { type: 'photo'; url: string; thumbnail?: string } | { type: 'video'; url: string; thumbnail: string; durationSec: number } | { type: 'voice'; url: string; durationSec: number; waveform?: number[] }; export interface MediaCarouselProps { items: MediaItem[]; initialIndex?: number; tileWidth?: number; // default 120 aspectRatio?: number; // default 5/4 onItemPress?: (index: number, item: MediaItem) => void; }
ProviderProfileHeader
Sits at the top of the Provider profile screen. The intro video thumbnail is the hero; the avatar overlaps the video bottom to anchor the identity. Verification chips and rating sit immediately below the name so trust signals load instantly.
// ProviderProfileHeader.tsx export interface ProviderProfileHeaderProps { provider: { id: string; name: string; headline: string; // e.g. "CV-monteur · Utrecht" avatarUrl?: string; avatarInitials: string; // fallback introVideo?: { thumbnail: string; durationSec: number }; rating: number | null; // null = no reviews yet reviewCount: number; verifications: { id: VerificationStatus; kvk: VerificationStatus; iban: VerificationStatus; }; fsValidated?: boolean; sectors: string[]; }; onPlayIntro?: () => void; onSectorPress?: (sector: string) => void; }
OtpInput
Phone verification + email OTP. Uses
textContentType="oneTimeCode" on iOS and
autoComplete="one-time-code" on Android so the OS
surfaces the SMS code in the keyboard. Auto-submits when the last
cell is filled.
// OtpInput.tsx export interface OtpInputProps { value: string; onChange: (next: string) => void; /** Fires when value.length reaches `length`. Triggers auto-submit. */ onComplete?: (code: string) => void; length?: number; // default 6 autoFocus?: boolean; error?: boolean; disabled?: boolean; }
import { useRef } from 'react'; import { View, TextInput, Platform } from 'react-native'; import { useTheme } from '@/design-system/theme'; export const OtpInput = ({ value, onChange, onComplete, length = 6, error, disabled }: OtpInputProps) => { const t = useTheme(); const hiddenRef = useRef<TextInput>(null); const handleChange = (raw: string) => { const next = raw.replace(/[^0-9]/g, '').slice(0, length); onChange(next); if (next.length === length) onComplete?.(next); }; return ( <View style={{ flexDirection: 'row', gap: t.space.sm }} onTouchEnd={() => hiddenRef.current?.focus()}> {Array.from({ length }).map((_, i) => ( <View key={i} style={cellStyle(t, value, i, error)}> <Text style={digitStyle(t)}>{value[i] ?? ''}</Text> </View> ))} <TextInput ref={hiddenRef} value={value} onChangeText={handleChange} keyboardType="number-pad" textContentType="oneTimeCode" // iOS — SMS suggestion autoComplete="one-time-code" // Android — SMS suggestion maxLength={length} editable={!disabled} style={hiddenInputStyle} // absolutely positioned, opacity 0 /> </View> ); };