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.
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-cardUse npm install instead of bun add if you prefer npm.
- metro.config.js (wrap with withUniwindConfig):
const { withUniwindConfig } = require('uniwind/metro');
module.exports = withUniwindConfig({
cssEntryFile: './global.css',
})({
// your metro config
});
withUniwindConfigmust be the outermost wrapper.cssEntryFilemust be a relative path string.
- 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);
}
}
}- 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).
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>
);
}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.
- Copy-Paste Ownership — Components live in your project. No
node_moduleslock-in. - Beautiful by Default — OKLCH color system, light/dark themes, polished out of the box.
- Fully Customizable — Every component uses
tv()variants and acceptsclassNameoverrides. - Accessible — Proper ARIA roles, keyboard support, controlled/uncontrolled state.
- Tree-Shakeable — Only imports what you use.
sideEffects: false.
- Full-screen:
AppShell+Headerinheader+BottomNavinbottomNav+ content inchildren - Layout:
Stack(vertical),Row(horizontal) — both supportgap,align,justify - Auth callbacks: Auth templates use objects:
onSubmit({ email, password }),onSubmit({ firstName, lastName, email, password }), etc. Copy templates fromapps/showcase/templates/auth/. - PortalHost: Add
<PortalHost />at app root for Select, Popover, Tooltip, HoverCard. - Uniwind:
withUniwindConfigmust wrap metro config.global.cssat app root, imported inApp.tsx. - Reference: Block props in
packages/ui/src/blocks(core) andapps/showcase/templates/(auth, profile); flow patterns in README "Flow Patterns" section.
| 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 |
| 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 |
| 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 |
| Component | Notes |
|---|---|
| Tabs | TabsList, TabsTrigger, TabsContent |
| Accordion | AccordionItem, AccordionTrigger, Content |
| Collapsible | CollapsibleTrigger, CollapsibleContent |
| 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 |
| Component | Notes |
|---|---|
| Toast | ToastProvider, ToastContainer, useToast() hook |
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 |
| 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 |
| 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 |
| Block | Description |
|---|---|
| Chat | AI-style chat: messages, suggestions, SmartInput |
| ChatMessageBubble | Message bubble (user/assistant, big/small text) |
| ChatSuggestions | Suggestion chips when empty |
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 |
| Block | Description |
|---|---|
| ListItem | Title/subtitle row |
| NotificationItem | Notification row with metadata |
| SwipeableItem | Swipe actions list item |
| TimelineFeed | Vertical feed with connecting lines |
| 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 |
| Block | Description |
|---|---|
| SmartImage | Image with aspect ratio and loading |
| Block | Description |
|---|---|
| DatePickerModal | Bottom sheet modal with calendar, compact trigger |
| DatePickerInline | Inline calendar for forms or dashboards |
| DateRangePickerModal | Range selection modal with period marking |
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.
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 | 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.
bun install
cd apps/showcase
bun run dev
# Or: npx expo start --webwhileui/
├── 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
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);
/* ... */
}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'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
muted→mutedForeground(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 });- Styling: Uniwind (free tier) + Tailwind CSS v4
- Variants: tailwind-variants (
tv()) - Merging: clsx + tailwind-merge
- Expo: SDK 54
- Monorepo: bun + Turborepo
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 |
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 |
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 |
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>;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>;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 |
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>;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 |
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' |
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' |
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>;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>;import { Checkbox } from '@thewhileloop/whileui';
<Checkbox checked={checked} onCheckedChange={setChecked} />;| Prop | Type | Default |
|---|---|---|
| checked | boolean |
false |
| onCheckedChange | (checked: boolean) => void |
— |
| disabled | boolean |
false |
import { Switch } from '@thewhileloop/whileui';
<Switch checked={checked} onCheckedChange={setChecked} />;| Prop | Type | Default |
|---|---|---|
| checked | boolean |
false |
| onCheckedChange | (checked: boolean) => void |
— |
import { RadioGroup, RadioGroupItem } from '@thewhileloop/whileui';
<RadioGroup value={value} onValueChange={setValue}>
<RadioGroupItem value="option1" />
<RadioGroupItem value="option2" />
</RadioGroup>;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>;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>;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 |
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' |
import { Progress } from '@thewhileloop/whileui';
<Progress value={50} size="default" />;| Prop | Type | Default |
|---|---|---|
| value | number |
0 |
| size | 'sm' | 'default' | 'lg' |
'default' |
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) |
import { Popover, PopoverTrigger, PopoverContent } from '@thewhileloop/whileui';
<Popover>
<PopoverTrigger asChild>
<Button>
<ButtonText>Open</ButtonText>
</Button>
</PopoverTrigger>
<PopoverContent>
<Text>Popover content</Text>
</PopoverContent>
</Popover>;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>;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 |
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 |
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) => {}}
/>;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>;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>
}
/>;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 |
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 |
import { ConfirmActionSheet } from '@thewhileloop/whileui';
<ConfirmActionSheet
open={open}
onOpenChange={setOpen}
title="Delete project?"
description="This action cannot be undone."
confirmLabel="Delete"
destructive
onConfirm={() => deleteProject()}
/>;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 |
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>}
/>;import { Header, HeaderBackButton } from '@thewhileloop/whileui';
<Header
title="Settings"
subtitle="Manage preferences"
leftAction={<HeaderBackButton onPress={() => {}} />}
rightActions={[{ key: 'search', icon: <Icon />, onPress: () => {} }]}
/>;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 |
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) |
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 |
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 |
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 |
import { EmptyState } from '@thewhileloop/whileui';
<EmptyState
icon={<Icon />}
title="No items"
description="Add your first item."
action={{ label: 'Add Item', onPress: () => {} }}
/>;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: () => {} }}
/>;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>;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' |
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={() => {}}
/>;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>}
/>;MIT — Source