Soft Pro Component Library

Build these components once, use them everywhere. Each one resolves light / dark through the theme.

Button 5 variants · 3 sizes · loading + disabled

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.

Variants · medium size
Sizes
States
PropTypeDefault
variant'primary' | 'secondary' | 'tertiary' | 'destructive' | 'ghost''primary'
size'sm' | 'md' | 'lg''md'
loadingbooleanfalse
disabledbooleanfalse
leftIcon · rightIconReactNode

IconButton 5 variants · 4 sizes · square + round · FAB

Same variant ladder as Button but icon-only — square by default, round for floating actions and avatar-adjacent controls. Always include an aria-label.

Variants · square · medium
Sizes
Round shape · for avatar-adjacent + floating actions
States
PropTypeDefault
iconReactNode (required)
aria-labelstring (required)
variant'primary' | 'secondary' | 'tertiary' | 'destructive' | 'ghost''secondary'
size'sm' | 'md' | 'lg' | 'xl''md'
shape'square' | 'round''square'
loadingbooleanfalse
disabledbooleanfalse
elevatedboolean — adds FAB-style shadowfalse

Input text · email · phone · number · error / success / focused

Variants
We'll send verification to this address.
+31
Dutch mobile number.
EUR per hour, excl. VAT.
States
Helper text
Please enter a valid email.
Email looks good.
Filled by KvK lookup.

Textarea multiline · character counter

137 / 240
Be specific — better descriptions get more bids.

Checkbox · Radio · Toggle

Checkbox
Radio · urgency
Toggle

Select · Picker category · urgency · location filters

Select (native)
Picker (custom — open state)
Critical · within the hour
High · today
Scheduled · this week
Flexible · no rush

Card · ElevatedCard

Flat Card for in-flow content. ElevatedCard for items that need to float — featured jobs, summary tiles.

OPEN
Lekkende kraan keuken
Utrecht · 3 km · €60–90/hr
Posted 14 min ago · 4 bids
FEATURED
CV-installatie Amsterdam
Amsterdam Oost · €1,200 fixed
Posted 2 hours ago · 11 bids

Avatar with fallback initials

Sizes
JV JV JV JV JV
Variants
JV FB SM PV

StatusPill job · bid · deal statuses

Job statuses
DRAFT OPEN PENDING APPROVAL CLOSED EXPIRED CANCELED
Bid statuses
PENDING ACCEPTED REJECTED WITHDRAWN EXPIRED
Deal statuses
ACCEPTED IN PROGRESS AWAITING CONFIRM CLOSED DISPUTED

Tag · Chip categories · filters · multi-select

Category chips (multi-select)
Filter chips

Modal · BottomSheet · Toast

Modal
BottomSheet

Filter jobs

Light
Bid placed — provider has been notified.
Couldn't reach the server. Pull to retry.
New message in your deal with Jan.
Dark
Bid placed — provider has been notified.
Couldn't reach the server. Pull to retry.
New message in your deal with Jan.

EmptyState · ErrorState · LoadingState · SkeletonLoader

📭
No bids yet
As soon as a provider places a bid, you'll see it here.
!
Couldn't load jobs
We're having trouble reaching the server. Check your connection and try again.
Loading jobs…
Hang tight — this usually takes a second.
SkeletonLoader — job card

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 8h / 16h / 24h / 48h windows · ample / halfway / near / expired

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.

Light · all states
Ample 7h 42m left
Halfway 4h 12m left
Near‑expiry 38m left
Expired Expired
Dark · all states
Ample 7h 42m left
Halfway 4h 12m left
Near‑expiry 38m left
Expired Expired
Props
// 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 verified · pending · not-linked

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.

Light · all states
Validated via For Suppliers Validation pending Not linked to For Suppliers
Dark · all states
Validated via For Suppliers Validation pending Not linked to For Suppliers
// 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 10 sectors · 2 variants · 3 sizes · Tabler icons

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.

Light · all 10 sectors (md)
Loodgieter Elektricien Timmerman Schilder Tegels / Badkamer Dak / Goten HVAC / Installatie Sloten / Beveiliging Klusjesman Kleine reparaties
Dark · all 10 sectors (md)
Loodgieter Elektricien Timmerman Schilder Tegels / Badkamer Dak / Goten HVAC / Installatie Sloten / Beveiliging Klusjesman Kleine reparaties
Light · accent variant · all 10 sectors
Loodgieter Elektricien Timmerman Schilder Tegels / Badkamer Dak / Goten HVAC / Installatie Sloten / Beveiliging Klusjesman Kleine reparaties
Dark · accent variant · all 10 sectors
Loodgieter Elektricien Timmerman Schilder Tegels / Badkamer Dak / Goten HVAC / Installatie Sloten / Beveiliging Klusjesman Kleine reparaties
Light · sm · md · lg
Elektricien Elektricien Elektricien
Dark · sm · md · lg
Tegels / Badkamer Tegels / Badkamer Tegels / Badkamer
// 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 boost indicator · 3 sizes · subtle gamification

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.

Light · sizes + with days-left
BOOST BOOSTED BOOST · 2 DAYS LEFT
Dark · sizes + with days-left
BOOST BOOSTED BOOST · 2 DAYS LEFT
// 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 media-first feed card · urgency · location · price · bid count

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.

Light · neutral SectorPill · standard job
42m left
0:24
Boiler defect — geen warm water
Loodgieter
Utrecht Oost 3 km
Dark · neutral SectorPill · standard job
21h left
Lekkende kraan keuken
Loodgieter
Amsterdam Zuid 6 km
Light · accent SectorPill · boosted job
4h left BOOST
Spoed: stoppenkast defect — geen stroom
Elektricien
Rotterdam Centrum 2 km
Dark · accent SectorPill · boosted job
4h left BOOST
Spoed: stoppenkast defect — geen stroom
Elektricien
Rotterdam Centrum 2 km

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.

Light · chartreuse watermark · 64×64 · 30% opacity
18h left
Keuken renoveren — tegelwerk + sanitair
Loodgieter
Den Haag Centrum 4 km
Dark · chartreuse watermark · 64×64 · 30% opacity
18h left
Keuken renoveren — tegelwerk + sanitair
Loodgieter
Den Haag Centrum 4 km
// 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 bid on JobDetail · verification badges · ratings · accept / decline

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.

Light · pending · accepted · declined
JV
Jan de Vries 4.9 · 142 reviews
PENDING
ID KvK IBAN FS
€85 / hr · 3-4 hrs

Beschikbaar binnen het uur. Heb een Remeha-gecertificeerde monteur. Onderdelen op voorraad.

SM
Sander Meijer 4.7 · 38 reviews
ACCEPTED
ID KvK
€140 fixed

Kan vanavond langskomen tussen 18:00 en 20:00. Ervaring met combiketels.

PV
Pieter Visser 3.8 · 24 reviews
DECLINED
ID
€110 / hr

Kan morgenochtend langs komen. Heb basisgereedschap mee.

Dark · pending · accepted · declined
JV
Jan de Vries 4.9 · 142 reviews
PENDING
ID KvK IBAN FS
€85 / hr · 3-4 hrs

Beschikbaar binnen het uur. Heb een Remeha-gecertificeerde monteur. Onderdelen op voorraad.

SM
Sander Meijer 4.7 · 38 reviews
ACCEPTED
ID KvK
€140 fixed

Kan vanavond langskomen tussen 18:00 en 20:00. Ervaring met combiketels.

PV
Pieter Visser 3.8 · 24 reviews
DECLINED
ID
€110 / hr

Kan morgenochtend langs komen. Heb basisgereedschap mee.

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

ProviderProfileHeader intro video · avatar · badges · ratings

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.

Light · with intro video · verified
0:42
JV
Jan de Vries
CV-monteur · Utrecht
4.9 · 142 reviews
ID verified KvK 7812 4451 IBAN
CV & ketels Loodgieter Sanitair
Dark · with intro video · new provider
0:31
SM
Sander Meijer
Loodgieter · Amsterdam
No reviews yet
ID verified KvK pending
Loodgieter Sanitair
Light · no intro video · verified
MT
Marieke ten Have
Stukadoor · Rotterdam
4.6 · 67 reviews
ID verified KvK 6122 0048 IBAN
Stucwerk Voegwerk
Dark · no intro video · new provider
DB
David Bakker
Schilder · Den Haag
No reviews yet
ID pending
Schilderwerk
// 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 6-digit auto-submit · native keyboard suggestion support

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.

Light · partial + complete + error
Partial — 3rd cell active
Complete
Error
Code expired. Tap "Resend" to try again.
Dark · partial + complete + error
Partial — 3rd cell active
Complete
Error
Code expired. Tap "Resend" to try again.
// 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>
  );
};