Skip to content

whileloophq/whileui

Repository files navigation

WhileUI Native

Copy-paste components for React Native. You own the code.

Beautiful, accessible, themeable components built with Uniwind + Tailwind CSS v4.

Requirements: React 19+, React Native 0.81+, Expo 52+ (if using Expo). Supports React Native Web.

Installation

bun add @thewhileloop/whileui
bun add react@^19.0.0 react-native@^0.81.0 uniwind@^1.0.0 tailwindcss@^4.0.0 react-native-reanimated react-native-safe-area-context react-native-screens
# Only if you use Select, Popover, Tooltip, or HoverCard:
bun add @rn-primitives/portal @rn-primitives/hooks @rn-primitives/slot @rn-primitives/select @rn-primitives/popover @rn-primitives/tooltip @rn-primitives/hover-card

Use npm install instead of bun add if you prefer npm.

Setup Uniwind (required)

  1. metro.config.js (wrap with withUniwindConfig):
const { withUniwindConfig } = require('uniwind/metro');

module.exports = withUniwindConfig({
  cssEntryFile: './global.css',
})({
  // your metro config
});

withUniwindConfig must be the outermost wrapper. cssEntryFile must be a relative path string.

  1. global.css at app root:
@import 'tailwindcss';
@import 'uniwind';

/* WhileUI Noir theme - copy this or create your own */
@layer theme {
  :root {
    @variant light {
      --color-background: oklch(1 0 0);
      --color-foreground: oklch(0.1316 0.0041 17.69);
      --color-card: oklch(1 0 0);
      --color-card-foreground: oklch(0.1316 0.0041 17.69);
      --color-primary: oklch(0.1316 0.0041 17.69);
      --color-primary-foreground: oklch(0.98 0 0);
      --color-secondary: oklch(0.9598 0.0017 17.69);
      --color-secondary-foreground: oklch(0.1316 0.0041 17.69);
      --color-muted: oklch(0.9598 0.0017 17.69);
      --color-muted-foreground: oklch(0.5415 0.0135 17.69);
      --color-accent: oklch(0.9598 0.0017 17.69);
      --color-accent-foreground: oklch(0.1316 0.0041 17.69);
      --color-destructive: oklch(0.5 0.19 27);
      --color-destructive-foreground: oklch(0.98 0 0);
      --color-border: oklch(0.9039 0.0034 17.69);
      --color-input: oklch(0.9039 0.0034 17.69);
      --color-ring: oklch(0.1316 0.0041 17.69);
      --color-success: oklch(0.59 0.16 145);
      --color-success-foreground: oklch(0.98 0 0);
      --color-warning: oklch(0.75 0.18 85);
      --color-warning-foreground: oklch(0.1316 0.0041 17.69);
      --color-info: oklch(0.65 0.15 245);
      --color-info-foreground: oklch(0.98 0 0);
    }

    @variant dark {
      --color-background: oklch(0.1316 0.0041 17.69);
      --color-foreground: oklch(0.98 0 0);
      --color-card: oklch(0.1316 0.0041 17.69);
      --color-card-foreground: oklch(0.98 0 0);
      --color-primary: oklch(0.98 0 0);
      --color-primary-foreground: oklch(0.1316 0.0041 17.69);
      --color-secondary: oklch(0.2104 0.0084 17.69);
      --color-secondary-foreground: oklch(0.98 0 0);
      --color-muted: oklch(0.2104 0.0084 17.69);
      --color-muted-foreground: oklch(0.6961 0.0174 17.69);
      --color-accent: oklch(0.2104 0.0084 17.69);
      --color-accent-foreground: oklch(0.98 0 0);
      --color-destructive: oklch(0.45 0.18 27);
      --color-destructive-foreground: oklch(0.98 0 0);
      --color-border: oklch(0.2104 0.0084 17.69);
      --color-input: oklch(0.2104 0.0084 17.69);
      --color-ring: oklch(0.8267 0.0206 17.69);
      --color-success: oklch(0.59 0.16 145);
      --color-success-foreground: oklch(0.98 0 0);
      --color-warning: oklch(0.75 0.18 85);
      --color-warning-foreground: oklch(0.1316 0.0041 17.69);
      --color-info: oklch(0.65 0.15 245);
      --color-info-foreground: oklch(0.98 0 0);
    }
  }
}
  1. App.tsx:
import './global.css';
import { PortalHost } from '@thewhileloop/whileui';
import { Button, ButtonText } from '@thewhileloop/whileui';

export default function App() {
  return (
    <>
      {/* Your app content */}
      <PortalHost />
    </>
  );
}

Note: If you use Select, Popover, Tooltip, or HoverCard, add <PortalHost /> at the root of your app (as the last child).

Usage

import {
  Button,
  ButtonText,
  Card,
  CardHeader,
  CardTitle,
  CardContent,
  Input,
  Text,
} from '@thewhileloop/whileui';

function MyScreen() {
  return (
    <Card>
      <CardHeader>
        <CardTitle>Welcome</CardTitle>
      </CardHeader>
      <CardContent>
        <Input placeholder="Email" />
        <Button className="mt-4">
          <ButtonText>Continue</ButtonText>
        </Button>
      </CardContent>
    </Card>
  );
}

Blocks Strategy: Core vs Templates

Core package exports:

  • Primitives — Button, Input, Card, Text, etc.
  • Generic layout blocks — EmptyState, ErrorState, LoadingScreen, ContentSkeleton, PageSkeleton
  • Layout infrastructure — FormModalScreen, ConfirmActionSheet, SmartInput, ActionBar
  • Navigation, Chat, Lists, Commerce, Media, DatePicker — Blocks that rarely need app-specific customization

Opinionated blocks (auth, profile) are copy-paste templates in the showcase app:

  • Auth: apps/showcase/templates/auth/ — SignInForm, SignUpForm, ForgotPasswordForm, ResetPasswordForm, VerifyEmailForm, SocialConnections, UserMenu
  • Profile: apps/showcase/templates/profile/ — ProfileHeader, SettingsSection, SettingsItem, AccountCard

To use auth or profile blocks: Copy the template file(s) from apps/showcase/templates/ into your app. Customize as needed (error handling, loading state, social auth slot, branding). Each template imports primitives from @thewhileloop/whileui.

Philosophy

  • Copy-Paste Ownership — Components live in your project. No node_modules lock-in.
  • Beautiful by Default — OKLCH color system, light/dark themes, polished out of the box.
  • Fully Customizable — Every component uses tv() variants and accepts className overrides.
  • Accessible — Proper ARIA roles, keyboard support, controlled/uncontrolled state.
  • Tree-Shakeable — Only imports what you use. sideEffects: false.

Quick Reference (AI / Code Generation)

  • Full-screen: AppShell + Header in header + BottomNav in bottomNav + content in children
  • Layout: Stack (vertical), Row (horizontal) — both support gap, align, justify
  • Auth callbacks: Auth templates use objects: onSubmit({ email, password }), onSubmit({ firstName, lastName, email, password }), etc. Copy templates from apps/showcase/templates/auth/.
  • PortalHost: Add <PortalHost /> at app root for Select, Popover, Tooltip, HoverCard.
  • Uniwind: withUniwindConfig must wrap metro config. global.css at app root, imported in App.tsx.
  • Reference: Block props in packages/ui/src/blocks (core) and apps/showcase/templates/ (auth, profile); flow patterns in README "Flow Patterns" section.

Components

Primitives

Component Notes
Text Themed text with variant support
View Themed view wrapper
Pressable Themed pressable wrapper
Stack Vertical flex layout with gap
Row Horizontal flex layout with gap
Box Flexible container with variants

Form Controls

Component Variants Notes
Button default, destructive, outline, secondary, ghost, link 4 sizes, ButtonText & ButtonIcon sub-components
Input default, error TextInput wrapper with themed styling
NumericInput default, error Numeric input with prefix/suffix slots, optional steppers, and compact size
FormField default, compact Compound API: FormField, FormLabel, FormControl, FormHint, FormMessage
LabeledField default, compact Field wrapper with label/helper/error plus left/right slots
Textarea Multi-line text input
Checkbox Controlled/uncontrolled, accessibility roles
Switch Controlled/uncontrolled, accessibility roles
RadioGroup RadioGroup + RadioGroupItem
Select Uses SelectOption type {value, label}. Includes SelectGroup, SelectLabel, SelectSeparator
Label Form field label
SegmentedControl default, pill; single select SegmentedControl, SegmentedControlItem, SegmentedControlItemText with wrapping layout support
Toggle default, outline ToggleText sub-component
ToggleGroup single, multiple Group of toggle items

Display

Component Variants Notes
Card padding (none, sm, default, lg), unstyled Compound: Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter
Badge default, secondary, destructive, outline, success BadgeText sub-component
Alert default, destructive, success, warning AlertTitle & AlertDescription
Avatar sm, default, lg AvatarImage + AvatarFallback
DataRow default, compact DataRow, DataRowLeft/Center/Right, DataRowLabel/Description/Value
Separator horizontal, vertical Themed divider
Progress sm, default, lg Value-based progress bar with accessibility
Spinner sm, default, lg ActivityIndicator wrapper
Skeleton pulse, shimmer Loading placeholder (pulse = opacity fade, shimmer = sweep)
AspectRatio Maintain aspect ratio container

Layout

Component Notes
Tabs TabsList, TabsTrigger, TabsContent
Accordion AccordionItem, AccordionTrigger, Content
Collapsible CollapsibleTrigger, CollapsibleContent

Overlays & Menus

Component Notes
Dialog Modal dialog with Header, Footer, Title, Description
AlertDialog Confirmation dialog with Action/Cancel buttons
Popover Position-aware popover (requires PortalHost)
Tooltip Position-aware tooltip (requires PortalHost)
DropdownMenu DropdownMenuTrigger, Content, Item, Label, Separator
ContextMenu Long-press context menu
HoverCard Position-aware hover card (requires PortalHost)
Menubar Horizontal menu bar

Feedback

Component Notes
Toast ToastProvider, ToastContainer, useToast() hook

Blocks (Pre-built Screens)

Auth (Copy from showcase templates)

Copy from apps/showcase/templates/auth/:

Block File Description
SignInForm sign-in-form.tsx Email/password sign in with callbacks
SignUpForm sign-up-form.tsx Registration form with callbacks
ForgotPasswordForm forgot-password-form.tsx Password reset request
ResetPasswordForm reset-password-form.tsx Set new password
VerifyEmailForm verify-email-form.tsx Email verification code input
SocialConnections social-connections.tsx OAuth provider buttons
UserMenu user-menu.tsx Profile dropdown for auth flows

Navigation

Block Description
AppShell Layout shell with header/footer/bottomNav slots
NavigationSidebar Sidebar nav with grouped sections and footer slot
Header Top app bar with back/actions
BottomNav Tab-style bottom navigation bar
FloatingBottomNav Elevated bottom nav with safe area support
TabBar Top tab bar with indicator
DrawerMenu Drawer with sections and items

Layout

Block Description
ActionBar Sticky bottom action row with safe-area padding
ConfirmActionSheet Reusable destructive confirmation sheet
Sheet Bottom sheet modal with header/content/footer slots
FormModalScreen Modal scaffold for forms with loading states
ContentSkeleton Page/content placeholder with variants (list, card, generic)
PageSkeleton Variant-based page layouts (dashboard, list, settings, card, generic)
ErrorBoundary React ErrorBoundary that renders ErrorState by default
EmptyState Empty content placeholder
ErrorState Error display with retry
LoadingScreen Full-screen loading indicator
PullToRefreshScrollView Themed ScrollView with RefreshControl (useThemeTokens for colors)
SmartInput Keyboard-aware compose input: left/center/right slots, bar/card variant
OnboardingScreen Onboarding flow screen
SplashScreen Branded splash (fade/scale/slide variants)
MinimalSplash Minimal monochrome splash
BrandedSplash Splash with brand imagery

Chat

Block Description
Chat AI-style chat: messages, suggestions, SmartInput
ChatMessageBubble Message bubble (user/assistant, big/small text)
ChatSuggestions Suggestion chips when empty

Profile & Settings (Copy from showcase templates)

Copy from apps/showcase/templates/profile/:

Block File Description
ProfileHeader profile-header.tsx Profile header with stats
AccountCard account-card.tsx Account summary card
SettingsSection settings-section.tsx Section header with optional action
SettingsItem settings-item.tsx Row for toggles/links/settings

Lists

Block Description
ListItem Title/subtitle row
NotificationItem Notification row with metadata
SwipeableItem Swipe actions list item
TimelineFeed Vertical feed with connecting lines

Commerce

Block Description
ProductCard Product card with badge/media
PricingCard Pricing tiers with feature list
CheckoutSummary Cart summary with line items
MetricCard Stats/progress card for dashboards

Media

Block Description
SmartImage Image with aspect ratio and loading

Date Picker

Block Description
DatePickerModal Bottom sheet modal with calendar, compact trigger
DatePickerInline Inline calendar for forms or dashboards
DateRangePickerModal Range selection modal with period marking

Layout Primitives (Stack, Row, Box)

Use Stack for vertical layouts, Row for horizontal layouts. Both support gap, align, and justify variants.

import { Stack, Row, Box } from '@thewhileloop/whileui';

<Stack gap="lg">
  <Text>Title</Text>
  <Row gap="md" justify="between">
    <Button>
      <ButtonText>Cancel</ButtonText>
    </Button>
    <Button>
      <ButtonText>Save</ButtonText>
    </Button>
  </Row>
</Stack>;
Prop Stack/Row Values
gap 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' Spacing between children
align 'start' | 'center' | 'end' | 'stretch' | 'baseline' Cross-axis alignment
justify 'start' | 'center' | 'end' | 'between' | 'around' | 'evenly' Main-axis alignment

Box provides optional padding and margin variants for consistent spacing.

Full-Screen Composition

Standard pattern: AppShell with header, scrollable children, and bottomNav.

import { AppShell, Header, BottomNav, ScrollView } from '@thewhileloop/whileui';

<AppShell
  header={<Header title="Home" rightActions={[...]} />}
  bottomNav={
    <BottomNav
      items={[...]}
      activeKey="home"
      onSelect={(key) => setTab(key)}
    />
  }
>
  <ScrollView className="flex-1 p-4">
    {/* Screen content */}
  </ScrollView>
</AppShell>

Flow Patterns

Flow Blocks
Auth SignInForm → SignUpForm → ForgotPasswordForm → VerifyEmailForm → ResetPasswordForm
Forms FormField + Input/NumericInput/LabeledField + DatePickerModal + FormModalScreen
Settings ProfileHeader + SettingsSection + SettingsItem (+ FormModalScreen for edits)
E-commerce ProductCard list → CheckoutSummary + ActionBar
Chat Chat + ChatSuggestions + SmartInput (attach, send). Extensible for images/tags
App shell AppShell + Header + BottomNav + content

Block props: see TypeScript interfaces in packages/ui/src/blocks.

Quick Start

bun install
cd apps/showcase
bun run dev
# Or: npx expo start --web

Project Structure

whileui/
├── packages/
│   └── ui/
│       └── src/
│           ├── components/    # All components (copy these!)
│           │   ├── button/
│           │   ├── card/
│           │   ├── form-field/
│           │   ├── numeric-input/
│           │   ├── segmented-control/
│           │   ├── data-row/
│           │   ├── dialog/
│           │   └── ...
│           ├── blocks/        # Pre-built screens (core only)
│           │   ├── chat/
│           │   ├── navigation/
│           │   ├── layout/
│           │   ├── lists/
│           │   ├── commerce/
│           │   ├── splash/
│           │   └── media/
│           ├── lib/           # Utilities
│           │   ├── cn.ts      # clsx + tailwind-merge
│           │   ├── tv.ts      # tailwind-variants re-export
│           │   ├── font-context.ts
│           │   └── theme-bridge.ts
│           └── index.ts       # Barrel export
├── apps/
│   └── showcase/              # Expo demo app
│       ├── templates/         # Copy-paste templates (auth, profile)
│       │   ├── auth/
│       │   └── profile/
│       ├── App.tsx            # Component showcase
│       ├── global.css         # Theme variables (OKLCH) — at app root!
│       └── metro.config.js    # Uniwind + monorepo config
└── package.json               # bun monorepo root

Theming

Themes are defined in global.css using CSS variables with OKLCH colors:

@variant light {
  --color-primary: oklch(0.6 0.2 160);
  --color-background: oklch(1 0 0);
  /* ... */
}

@variant dark {
  --color-primary: oklch(0.6 0.2 160);
  --color-background: oklch(0.145 0 0);
  /* ... */
}

Strict Theme Token Contract

The WhileUI token contract is strict for cross-app reuse. Define these in every theme variant (@variant light, @variant dark, and custom variants):

  • Required core tokens: background, foreground, card, card-foreground, primary, primary-foreground, secondary, secondary-foreground, muted, muted-foreground, accent, accent-foreground, destructive, destructive-foreground, border, input, ring
  • Optional status tokens: success, success-foreground, warning, warning-foreground, info, info-foreground
  • Optional scale tokens: spacing (--spacing, --spacing-*), typography (--text-*, --leading-*, --tracking-*), radius (--radius-*), elevation (--shadow-*)

Minimal contract example:

@layer theme {
  :root {
    @variant light {
      --color-background: oklch(1 0 0);
      --color-foreground: oklch(0.15 0 0);
      --color-card: oklch(1 0 0);
      --color-card-foreground: oklch(0.15 0 0);
      --color-primary: oklch(0.2 0 0);
      --color-primary-foreground: oklch(0.98 0 0);
      --color-secondary: oklch(0.95 0 0);
      --color-secondary-foreground: oklch(0.15 0 0);
      --color-muted: oklch(0.95 0 0);
      --color-muted-foreground: oklch(0.45 0 0);
      --color-accent: oklch(0.9 0.05 180);
      --color-accent-foreground: oklch(0.15 0 0);
      --color-destructive: oklch(0.58 0.2 26);
      --color-destructive-foreground: oklch(0.98 0 0);
      --color-border: oklch(0.9 0 0);
      --color-input: oklch(0.92 0 0);
      --color-ring: oklch(0.22 0 0);
    }
  }
}

Switch themes at runtime via Uniwind:

import { Uniwind } from 'uniwind';

Uniwind.setTheme('dark'); // or 'light' or 'system'

Theme Colors for RN Primitives

Some React Native APIs require native color strings (hex/rgb/hsl/named). Use useThemeColors or useIconColors to read from your global.css theme. If your theme uses oklch(...), add --app-color-* hex fallbacks — useThemeColors will use them when --color-* is not RN-native.

import { useThemeColors, useIconColors } from '@thewhileloop/whileui';
import { Feather } from '@expo/vector-icons';

// Full palette (primary, foreground, muted, background, border, accent, destructive, etc.)
const colors = useThemeColors();

// Shorthand for icons: foreground, muted, primary, primaryForeground, accent, destructive
const iconColors = useIconColors();

<Feather name="heart" size={20} color={iconColors.muted} />
<Spinner color={colors.foreground} />  // Spinner defaults to this when color not passed
  • useThemeColors / useThemeTokens — Returns RN-safe color strings (hex) for all semantic tokens. Use for RefreshControl, LinearGradient, charts. Falls back to --app-color-* when --color-* is not RN-native (e.g. oklch(...)).
  • useIconColors — Subset for icons. Maps mutedmutedForeground (readable on backgrounds).

Input, Textarea, NumericInput, SmartInput, Spinner, and LoadingScreen default to theme colors when you omit placeholderTextColor or spinnerColor.

Optional RN fallback tokens (hex/rgb/hsl/named):

@layer theme {
  :root {
    @variant light {
      --app-color-primary: #000000;
      --app-color-primary-foreground: #ffffff;
      --app-color-foreground: #000000;
      --app-color-muted: #f5f5f5;
      --app-color-muted-foreground: #737373;
      --app-color-background: #ffffff;
      --app-color-border: #e5e5e5;
      --app-color-accent: #22c55e;
      --app-color-destructive: #dc2626;
    }
    @variant dark {
      --app-color-primary: #ffffff;
      --app-color-primary-foreground: #000000;
      --app-color-foreground: #ffffff;
      --app-color-muted: #2e2e2e;
      --app-color-muted-foreground: #999999;
      --app-color-background: #000000;
      --app-color-border: #3d3d3d;
      --app-color-accent: #22c55e;
      --app-color-destructive: #dc2626;
    }
  }
}

Or use the first-party ThemeBridge helper with optional persistence:

import { useThemeBridge, type ThemeBridgeAdapter } from '@thewhileloop/whileui';

const adapter: ThemeBridgeAdapter = {
  loadThemeMode: async () => 'system',
  saveThemeMode: async (mode) => {
    await storage.setItem('theme-mode', mode);
  },
};

const { mode, resolvedTheme, setMode, cycleMode } = useThemeBridge({ adapter });

Tech Stack

API Reference

Button

import { Button, ButtonText, ButtonIcon } from '@thewhileloop/whileui';

<Button variant="default" size="default" disabled={false} onPress={() => {}}>
  <ButtonIcon>
    <Icon />
  </ButtonIcon>
  <ButtonText>Click me</ButtonText>
  <ButtonIcon position="right">
    <Icon />
  </ButtonIcon>
</Button>;
Prop Type Default Description
variant 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link' 'default' Button style variant
size 'default' | 'sm' | 'lg' | 'icon' 'default' Button size
disabled boolean false Disable the button
className string Additional Tailwind classes

Input

import { Input } from '@thewhileloop/whileui';

<Input placeholder="Email" variant="default" value={value} onChangeText={setValue} />;
Prop Type Default Description
variant 'default' | 'error' 'default' Input style variant
placeholder string Placeholder text
placeholderTextColor string Hex for placeholder; defaults to theme mutedForeground
editable boolean true Whether input is editable

NumericInput

import { NumericInput } from '@thewhileloop/whileui';

<NumericInput
  value={amount}
  onValueChange={setAmount}
  prefix={<Text className="text-muted-foreground">$</Text>}
  suffix={<Text className="text-muted-foreground">USD</Text>}
  min={0}
  step={0.5}
  showSteppers
  size="compact"
/>;
Prop Type Default Description
variant 'default' | 'error' 'default' Input style
size 'default' | 'compact' 'default' Density size
value number | null Controlled numeric value
onValueChange (value: number | null) => void Numeric value change callback
placeholderTextColor string Hex for placeholder
prefix / suffix ReactNode Left/right slots
showSteppers boolean false Show decrement/increment controls

FormField

import {
  FormField,
  FormLabel,
  FormControl,
  FormHint,
  FormMessage,
  Input,
} from '@thewhileloop/whileui';

<FormField required invalid={Boolean(error)}>
  <FormLabel>Email</FormLabel>
  <FormControl>
    <Input placeholder="you@example.com" />
  </FormControl>
  {error ? <FormMessage>{error}</FormMessage> : <FormHint>We'll never share your email.</FormHint>}
</FormField>;

LabeledField

import { LabeledField, LabeledFieldControl, Input } from '@thewhileloop/whileui';

<LabeledField
  label="Username"
  hint="3-20 characters"
  leftSlot={<Icon />}
  rightSlot={
    <Button size="sm">
      <ButtonText>Check</ButtonText>
    </Button>
  }
>
  <LabeledFieldControl>
    <Input className="border-0 bg-transparent px-0" />
  </LabeledFieldControl>
</LabeledField>;

SegmentedControl

import {
  SegmentedControl,
  SegmentedControlItem,
  SegmentedControlItemText,
} from '@thewhileloop/whileui';

<SegmentedControl value={unit} onValueChange={setUnit} variant="pill" wrap>
  <SegmentedControlItem value="metric">
    <SegmentedControlItemText>Metric</SegmentedControlItemText>
  </SegmentedControlItem>
  <SegmentedControlItem value="imperial">
    <SegmentedControlItemText>Imperial</SegmentedControlItemText>
  </SegmentedControlItem>
</SegmentedControl>;
Prop Type Default Description
variant 'default' | 'pill' 'default' Pill = rounded-full items

DataRow

import {
  DataRow,
  DataRowLeft,
  DataRowCenter,
  DataRowRight,
  DataRowLabel,
  DataRowDescription,
  DataRowValue,
  Avatar,
  AvatarFallback,
} from '@thewhileloop/whileui';

<DataRow>
  <DataRowLeft>
    <Avatar size="sm">
      <AvatarFallback>JD</AvatarFallback>
    </Avatar>
  </DataRowLeft>
  <DataRowCenter>
    <DataRowLabel>Jane Doe</DataRowLabel>
    <DataRowDescription>Product Designer</DataRowDescription>
  </DataRowCenter>
  <DataRowRight>
    <DataRowValue>Owner</DataRowValue>
  </DataRowRight>
</DataRow>;

Card

import {
  Card,
  CardHeader,
  CardTitle,
  CardDescription,
  CardContent,
  CardFooter,
} from '@thewhileloop/whileui';

<Card padding="default">
  <CardHeader>
    <CardTitle>Title</CardTitle>
    <CardDescription>Description</CardDescription>
  </CardHeader>
  <CardContent>{/* Content */}</CardContent>
  <CardFooter>{/* Footer */}</CardFooter>
</Card>;

<Card unstyled padding="none" className="rounded-xl border border-border bg-card">
  {/* advanced custom layouts */}
</Card>;
Prop Type Default Description
padding 'none' | 'sm' | 'default' | 'lg' 'default' Card interior padding
unstyled boolean false Remove built-in card surface styles

Badge

import { Badge, BadgeText } from '@thewhileloop/whileui';

<Badge variant="default">
  <BadgeText>New</BadgeText>
</Badge>;
Prop Type Default
variant 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'info' 'default'

Alert

import { Alert, AlertTitle, AlertDescription } from '@thewhileloop/whileui';

<Alert variant="default">
  <AlertTitle>Heads up!</AlertTitle>
  <AlertDescription>Something happened.</AlertDescription>
</Alert>;
Prop Type Default
variant 'default' | 'destructive' | 'success' | 'warning' 'default'

Dialog

import {
  Dialog,
  DialogTrigger,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogDescription,
  DialogFooter,
  DialogClose,
} from '@thewhileloop/whileui';

<Dialog>
  <DialogTrigger asChild>
    <Button>
      <ButtonText>Open</ButtonText>
    </Button>
  </DialogTrigger>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>Title</DialogTitle>
      <DialogDescription>Description</DialogDescription>
    </DialogHeader>
    {/* Content */}
    <DialogFooter>
      <DialogClose asChild>
        <Button>
          <ButtonText>Close</ButtonText>
        </Button>
      </DialogClose>
    </DialogFooter>
  </DialogContent>
</Dialog>;

AlertDialog

import {
  AlertDialog,
  AlertDialogTrigger,
  AlertDialogContent,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogAction,
  AlertDialogCancel,
} from '@thewhileloop/whileui';

<AlertDialog>
  <AlertDialogTrigger asChild>
    <Button variant="destructive">
      <ButtonText>Delete</ButtonText>
    </Button>
  </AlertDialogTrigger>
  <AlertDialogContent>
    <AlertDialogHeader>
      <AlertDialogTitle>Are you sure?</AlertDialogTitle>
      <AlertDialogDescription>This action cannot be undone.</AlertDialogDescription>
    </AlertDialogHeader>
    <AlertDialogFooter>
      <AlertDialogCancel>Cancel</AlertDialogCancel>
      <AlertDialogAction>Delete</AlertDialogAction>
    </AlertDialogFooter>
  </AlertDialogContent>
</AlertDialog>;

Checkbox

import { Checkbox } from '@thewhileloop/whileui';

<Checkbox checked={checked} onCheckedChange={setChecked} />;
Prop Type Default
checked boolean false
onCheckedChange (checked: boolean) => void
disabled boolean false

Switch

import { Switch } from '@thewhileloop/whileui';

<Switch checked={checked} onCheckedChange={setChecked} />;
Prop Type Default
checked boolean false
onCheckedChange (checked: boolean) => void

RadioGroup

import { RadioGroup, RadioGroupItem } from '@thewhileloop/whileui';

<RadioGroup value={value} onValueChange={setValue}>
  <RadioGroupItem value="option1" />
  <RadioGroupItem value="option2" />
</RadioGroup>;

Select

import {
  Select,
  SelectTrigger,
  SelectValue,
  SelectContent,
  SelectItem,
} from '@thewhileloop/whileui';

<Select value={value} onValueChange={setValue}>
  <SelectTrigger>
    <SelectValue placeholder="Select..." />
  </SelectTrigger>
  <SelectContent>
    <SelectItem label="Option 1" value="1" />
    <SelectItem label="Option 2" value="2" />
  </SelectContent>
</Select>;

Tabs

import { Tabs, TabsList, TabsTrigger, TabsContent } from '@thewhileloop/whileui';

<Tabs defaultValue="tab1">
  <TabsList>
    <TabsTrigger value="tab1">
      <Text>Tab 1</Text>
    </TabsTrigger>
    <TabsTrigger value="tab2">
      <Text>Tab 2</Text>
    </TabsTrigger>
  </TabsList>
  <TabsContent value="tab1">{/* Content 1 */}</TabsContent>
  <TabsContent value="tab2">{/* Content 2 */}</TabsContent>
</Tabs>;

Accordion

import {
  Accordion,
  AccordionItem,
  AccordionTrigger,
  AccordionContent,
} from '@thewhileloop/whileui';

<Accordion type="single" collapsible>
  <AccordionItem value="item1">
    <AccordionTrigger>
      <Text>Section 1</Text>
    </AccordionTrigger>
    <AccordionContent>
      <Text>Content 1</Text>
    </AccordionContent>
  </AccordionItem>
</Accordion>;
Prop Type Default
type 'single' | 'multiple' 'single'
collapsible boolean false

Avatar

import { Avatar, AvatarImage, AvatarFallback } from '@thewhileloop/whileui';

<Avatar size="default">
  <AvatarImage src="https://..." />
  <AvatarFallback>JD</AvatarFallback>
</Avatar>;
Prop Type Default
size 'sm' | 'default' | 'lg' 'default'

Progress

import { Progress } from '@thewhileloop/whileui';

<Progress value={50} size="default" />;
Prop Type Default
value number 0
size 'sm' | 'default' | 'lg' 'default'

Toast

import { ToastProvider, ToastContainer, useToast } from '@thewhileloop/whileui';

// Wrap app
<ToastProvider>
  <App />
  <ToastContainer position="top" />
</ToastProvider>;

// Use in component
const { toast } = useToast();
toast({ title: 'Success', description: 'Saved!', variant: 'success' });
Toast Options Type
title string
description string
variant 'default' | 'success' | 'destructive'
duration number (ms)

Popover

import { Popover, PopoverTrigger, PopoverContent } from '@thewhileloop/whileui';

<Popover>
  <PopoverTrigger asChild>
    <Button>
      <ButtonText>Open</ButtonText>
    </Button>
  </PopoverTrigger>
  <PopoverContent>
    <Text>Popover content</Text>
  </PopoverContent>
</Popover>;

DropdownMenu

import {
  DropdownMenu,
  DropdownMenuTrigger,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
} from '@thewhileloop/whileui';

<DropdownMenu>
  <DropdownMenuTrigger asChild>
    <Button>
      <ButtonText>Menu</ButtonText>
    </Button>
  </DropdownMenuTrigger>
  <DropdownMenuContent>
    <DropdownMenuLabel>Actions</DropdownMenuLabel>
    <DropdownMenuSeparator />
    <DropdownMenuItem>
      <Text>Edit</Text>
    </DropdownMenuItem>
    <DropdownMenuItem>
      <Text>Delete</Text>
    </DropdownMenuItem>
  </DropdownMenuContent>
</DropdownMenu>;

Blocks API

SignInForm

Copy from apps/showcase/templates/auth/sign-in-form.tsx, then:

import { SignInForm } from './templates/auth'; // or your path

<SignInForm
  onSubmit={({ email, password }) => signIn(email, password)}
  onForgotPassword={() => navigate('ForgotPassword')}
  onSignUp={() => navigate('SignUp')}
  onGooglePress={() => signInWithGoogle()}
  onApplePress={() => signInWithApple()}
/>;
Prop Type Description
onSubmit (data: { email, password }) => void Called on sign-in submit
onForgotPassword () => void Called when "Forgot?" tapped
onSignUp () => void Called when "Sign Up" tapped
onGooglePress () => void Called when Google tapped
onApplePress () => void Called when Apple tapped

SignUpForm

Copy from apps/showcase/templates/auth/sign-up-form.tsx, then:

import { SignUpForm } from './templates/auth'; // or your path

<SignUpForm
  onSubmit={({ firstName, lastName, email, password }) =>
    signUp(firstName, lastName, email, password)
  }
  onSignIn={() => navigate('SignIn')}
  onGooglePress={() => signInWithGoogle()}
  onApplePress={() => signInWithApple()}
/>;
Prop Type Description
onSubmit (data: { firstName, lastName, email, password }) => void Called on registration
onSignIn () => void Called when "Sign In" tapped
onGooglePress () => void Called when Google tapped
onApplePress () => void Called when Apple tapped

BottomNav

import { BottomNav } from '@thewhileloop/whileui';

<BottomNav
  items={[
    { key: 'home', label: 'Home', icon: <Icon /> },
    { key: 'profile', label: 'Profile', icon: <Icon />, badge: 3 },
  ]}
  activeKey="home"
  onSelect={(key) => {}}
/>;

ActionBar

import { ActionBar, Button, ButtonText } from '@thewhileloop/whileui';

<ActionBar>
  <Button variant="outline" className="flex-1">
    <ButtonText>Cancel</ButtonText>
  </Button>
  <Button className="flex-1">
    <ButtonText>Save</ButtonText>
  </Button>
</ActionBar>;

SmartInput

Keyboard-aware compose input. variant: "bar" (sticky bottom) or "card" (floating). Slots: leftSlot, centerSlot (intent selector), rightSlot. submitBehavior: "newline" (default) or "submit"/"blurAndSubmit". Forwards ref to TextInput.

import { SmartInput, Button, ButtonText } from '@thewhileloop/whileui';

<SmartInput
  value={message}
  onChangeText={setMessage}
  placeholder="Type a message..."
  leftSlot={
    <Button variant="ghost" size="icon">
      <Icon name="add" />
    </Button>
  }
  rightSlot={
    <Button size="icon" onPress={handleSend}>
      <ButtonText>Send</ButtonText>
    </Button>
  }
/>;

Chat

AI-style chat: message list, suggestion chips when empty, SmartInput with attach/send slots. Uses semantic tokens; theme via global.css. Copy-paste block — edit directly. renderMessage for markdown/code, loadingIndicator for typing state.

import { Chat, type ChatMessage } from '@thewhileloop/whileui';

const [messages, setMessages] = useState<ChatMessage[]>([...]);
const [value, setValue] = useState('');

<Chat
  messages={messages}
  value={value}
  onChangeText={setValue}
  onSend={() => { /* append user msg, clear input */ }}
  placeholder="Message..."
  suggestions={['Summarize this', 'Explain simply', 'Translate']}
  onSuggestionPress={(text) => setValue(text)}
  emptyTitle="How can I help?"
  emptyDescription="Ask anything."
  leftSlot={<Button variant="ghost" size="icon"><Icon name="paperclip" /></Button>}
  rightSlot={<Button size="icon" onPress={handleSend}><Icon name="send" /></Button>}
/>;
Prop Type Description
messages ChatMessage[] { id, role, content, secondary?, contentSize? }
value string Input value
onChangeText (text) => void Input change
onSend () => void Send handler
suggestions string[] Chips when empty
leftSlot / rightSlot ReactNode Attach, send, etc.
exampleMessage ChatMessage Shown in empty state
renderMessage (msg) => ReactNode Custom message (markdown, code, images)
loadingIndicator ReactNode Shown when loading (typing dots)
inputSafeArea boolean SmartInput safe-area (default true)
keyboardVerticalOffset number For header offset when keyboard opens

DatePickerModal / DatePickerInline / DateRangePickerModal

Date selection blocks using react-native-calendars. Theme-aware via useCalendarTheme (Uniwind light/dark). Optional theme prop for custom RN color strings (hex/rgb/hsl/named). CalendarTheme maps to react-native-calendars Theme and supports arrowColor, disabledArrowColor, and optional font keys.

DatePickerModal — Compact trigger opens bottom sheet with calendar. Use DatePickerTrigger as the trigger content.

DatePickerInline — Calendar embedded inline for forms or dashboards.

DateRangePickerModal — Range selection with period marking. Use DateRangePickerTrigger as the trigger content.

import {
  DatePickerModal,
  DatePickerTrigger,
  DatePickerInline,
  DateRangePickerModal,
  DateRangePickerTrigger,
  type DateRange,
} from '@thewhileloop/whileui';

// Single date (modal)
const [date, setDate] = useState<string | null>(null);
const [open, setOpen] = useState(false);

<DatePickerModal
  value={date}
  onValueChange={setDate}
  open={open}
  onOpenChange={setOpen}
  trigger={<DatePickerTrigger value={date} placeholder="Pick a date" />}
  title="Select date"
/>;

// Inline calendar
<DatePickerInline value={date} onValueChange={setDate} />;

// Date range (modal)
const [range, setRange] = useState<DateRange | null>(null);

<DateRangePickerModal
  value={range}
  onValueChange={setRange}
  open={rangeOpen}
  onOpenChange={setRangeOpen}
  trigger={<DateRangePickerTrigger value={range} placeholder="Pick range" />}
/>;
Prop Type Description
value string | null / DateRange | null Selected date(s) YYYY-MM-DD
onValueChange (date) => void Change handler
open / onOpenChange Modal state (modal variants)
trigger ReactNode Custom trigger (modal variants)
minDate / maxDate string YYYY-MM-DD bounds
theme CalendarTheme Override calendar colors

ConfirmActionSheet

import { ConfirmActionSheet } from '@thewhileloop/whileui';

<ConfirmActionSheet
  open={open}
  onOpenChange={setOpen}
  title="Delete project?"
  description="This action cannot be undone."
  confirmLabel="Delete"
  destructive
  onConfirm={() => deleteProject()}
/>;

Sheet

Bottom sheet modal with slide animation. Slots: SheetHeader, SheetContent, SheetFooter, SheetClose.

import {
  Sheet,
  SheetHeader,
  SheetContent,
  SheetFooter,
  SheetClose,
  Button,
  ButtonText,
} from '@thewhileloop/whileui';

<Sheet open={open} onOpenChange={setOpen} maxHeight="half">
  <SheetHeader title="Settings" description="Adjust preferences" />
  <SheetContent>{/* Scrollable body */}</SheetContent>
  <SheetFooter>
    <SheetClose asChild>
      <Button>
        <ButtonText>Save</ButtonText>
      </Button>
    </SheetClose>
  </SheetFooter>
</Sheet>;
Prop Type Default
open boolean
onOpenChange (open: boolean) => void
maxHeight 'half' | 'full' | number 'full'
maxWidth number 360

NavigationSidebar

import { NavigationSidebar } from '@thewhileloop/whileui';

<NavigationSidebar
  sections={[
    {
      title: 'Workspace',
      items: [
        { key: 'overview', label: 'Overview', icon: <Icon /> },
        { key: 'billing', label: 'Billing', icon: <Icon />, badge: 2 },
      ],
    },
  ]}
  activeKey="overview"
  onSelect={(key) => {}}
  header={<Text>Acme Inc.</Text>}
  footer={<Text className="text-xs text-muted-foreground">v1.0.0</Text>}
/>;

Header

import { Header, HeaderBackButton } from '@thewhileloop/whileui';

<Header
  title="Settings"
  subtitle="Manage preferences"
  leftAction={<HeaderBackButton onPress={() => {}} />}
  rightActions={[{ key: 'search', icon: <Icon />, onPress: () => {} }]}
/>;

SplashScreen

import { SplashScreen } from '@thewhileloop/whileui';

<SplashScreen
  logo={<MyLogo />}
  appName="MyApp"
  tagline="Your tagline"
  variant="scale"
  duration={1500}
  showLoading
  onAnimationComplete={() => {}}
/>;
Prop Type Default
variant 'fade' | 'scale' | 'slide' 'scale'
duration number 800
showLoading boolean false

ContentSkeleton

Page/content placeholder with layout presets. Use while loading data instead of a spinner when you want to preview the layout.

import { ContentSkeleton } from '@thewhileloop/whileui';

<ContentSkeleton variant="list" rows={4} />
<ContentSkeleton variant="card" />
<ContentSkeleton variant="generic" />
Prop Type Default Description
variant 'list' | 'card' | 'generic' 'list' Layout preset
rows number 4 Number of list rows (list variant only)

PageSkeleton

Variant-based page layouts for loading states. Replaces app-specific skeletons with presets for dashboard, list, settings, card, and generic pages.

import { PageSkeleton } from '@thewhileloop/whileui';

<PageSkeleton variant="dashboard" />
<PageSkeleton variant="list" count={5} />
<PageSkeleton variant="settings" count={6} />
<PageSkeleton variant="card" />
<PageSkeleton variant="generic" />
<PageSkeleton variant="list" padding="none" className="flex-1" />
Prop Type Default Description
variant 'dashboard' | 'list' | 'settings' | 'card' | 'generic' required Layout preset
count number 3 (list), 4 (settings) Rows/items for list or settings variant
padding 'none' | 'sm' | 'default' | 'lg' 'default' Container padding
className string Outer container classes

ErrorBoundary

React ErrorBoundary that catches render errors and renders ErrorState by default.

import { ErrorBoundary } from '@thewhileloop/whileui';

<ErrorBoundary onError={(err) => console.error(err)}>
  <App />
</ErrorBoundary>;
Prop Type Description
fallback ReactNode | (error, reset) => ReactNode Custom fallback; defaults to ErrorState
onError (error, errorInfo) => void Called when error is caught

PullToRefreshScrollView

Themed ScrollView with RefreshControl. Uses useThemeTokens for spinner color.

import { PullToRefreshScrollView } from '@thewhileloop/whileui';

<PullToRefreshScrollView refreshing={refreshing} onRefresh={fetchData} refreshColor="#22c55e">
  {content}
</PullToRefreshScrollView>;
Prop Type Description
refreshing boolean Whether refresh is in progress
onRefresh () => void Called when user pulls to refresh
refreshColor string Optional hex override; defaults to primary

EmptyState

import { EmptyState } from '@thewhileloop/whileui';

<EmptyState
  icon={<Icon />}
  title="No items"
  description="Add your first item."
  action={{ label: 'Add Item', onPress: () => {} }}
/>;

ProfileHeader

Copy from apps/showcase/templates/profile/profile-header.tsx, then:

import { ProfileHeader } from './templates/profile'; // or your path

<ProfileHeader
  name="John Doe"
  username="johndoe"
  bio="Designer & Developer"
  avatarFallback="JD"
  avatarUrl="https://..."
  verified
  stats={[
    { label: 'Followers', value: '1.2K' },
    { label: 'Following', value: 234 },
  ]}
  action={{ label: 'Edit Profile', onPress: () => {} }}
/>;

SettingsSection / SettingsItem

Copy from apps/showcase/templates/profile/, then:

import { SettingsSection, SettingsItem } from './templates/profile'; // or your path

<SettingsSection title="Preferences">
  <SettingsItem
    icon={<Icon />}
    label="Notifications"
    type="toggle"
    toggleValue={enabled}
    onToggle={setEnabled}
  />
  <SettingsItem icon={<Icon />} label="Privacy" value="Public" onPress={() => {}} />
  <SettingsItem icon={<Icon />} label="Sign Out" type="action" destructive />
</SettingsSection>;

ProductCard

import { ProductCard } from '@thewhileloop/whileui';

<ProductCard
  title="Product Name"
  price="$99"
  originalPrice="$129"
  badge="-23%"
  rating={4.5}
  reviewCount={128}
  variant="vertical"
  onPress={() => {}}
/>;
Prop Type Default
variant 'vertical' | 'horizontal' 'vertical'

PricingCard

import { PricingCard } from '@thewhileloop/whileui';

<PricingCard
  name="Pro"
  description="For teams"
  price="$29"
  period="/month"
  badge="Popular"
  highlighted
  features={[
    { label: 'Unlimited users', included: true },
    { label: 'Priority support', included: true },
    { label: 'Custom domain', included: false },
  ]}
  onPress={() => {}}
/>;

DrawerMenu

import { DrawerMenu } from '@thewhileloop/whileui';

<DrawerMenu
  visible={open}
  onClose={() => setOpen(false)}
  sections={[
    {
      title: 'Menu',
      items: [
        { key: 'home', label: 'Home', icon: <Icon /> },
        { key: 'settings', label: 'Settings', icon: <Icon /> },
      ],
    },
  ]}
  activeKey="home"
  onSelect={(key) => {}}
  header={<View>...</View>}
  footer={<Text>v1.0</Text>}
/>;

License

MIT — Source

About

Copy-paste components for React Native. You own the code.

Topics

Resources

License

Contributing

Security policy

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published