Bloom
Shared UI component library for the Oxy ecosystem. Built for React Native + Expo + Web.
Install
bun add @oxyhq/bloom
Peer dependencies
Required:
react >= 18react-native >= 0.73react-native-safe-area-context >= 5
Optional:
react-native-reanimated >= 3(nativeDialog,BottomSheet, and the nativeLoadingspinner /topvariant). Web never imports reanimated —Loadingships a CSS-animated web fork, so a plain web bundle needs none of these native peers.react-native-gesture-handler >= 2(nativeDialog,BottomSheet) — also requires wrapping the app root withGestureHandlerRootView, see Dialog.react-native-svg >= 13(Avatarsquircleshape)sonner >= 2/sonner-native >= 0.17(toast)
Usage
Theme
Wrap your app with BloomThemeProvider. It accepts controlled mode and colorPreset props — persist them however you like (AsyncStorage, Zustand, etc.).
import { BloomThemeProvider } from '@oxyhq/bloom/theme';
<BloomThemeProvider mode="system" colorPreset="teal">
<App />
</BloomThemeProvider>
Access theme values in any component:
import { useTheme } from '@oxyhq/bloom/theme';
const theme = useTheme();
// theme.colors.primary, theme.colors.text, theme.isDark, etc.
10 color presets: teal, blue, green, amber, red, purple, pink, sky, orange, mint.
4 modes: light, dark, system, adaptive (uses iOS/Android native dynamic colors when available).
Overlay components: Dialog, BottomSheet, toast / alert
Bloom ships two overlay surface components plus feedback helpers:
| Component | Use when |
|---|---|
Dialog |
All modal surfaces — centered card, side-sheet (left/right), or bottom-sheet — via the placement prop. Confirmation flows AND custom content. |
BottomSheet |
You need direct control over snap points, scroll handoff, gesture coordination, or detached presentation. |
toast / alert |
Passive feedback (toast) or one-shot confirmations (alert) called imperatively from anywhere. |
Removed in 0.16.x:
CenteredDialogandResponsiveSheetno longer exist. Migrate:<CenteredDialog visible={v} onClose={c}>…</CenteredDialog>→<Dialog placement="center" open={v} onClose={c}>…</Dialog>;<ResponsiveSheet side="left" open={o} onClose={c}>…</ResponsiveSheet>→<Dialog placement={{ base:'bottom', md:'left' }} open={o} onClose={c}>…</Dialog>.
Dialog
A single <Dialog> component for every overlay surface — centered modal, side-sheet, or bottom-sheet — selected by the placement prop. Same component and same props on every platform.
Required providers (native). Your app root must be wrapped with
GestureHandlerRootViewfromreact-native-gesture-handlerfor the bottom-sheet pan gestures to work.
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { BloomThemeProvider, BloomDialogProvider } from '@oxyhq/bloom';
export default function Root() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<BloomThemeProvider mode="system" colorPreset="oxy">
<BloomDialogProvider>
<App />
</BloomDialogProvider>
</BloomThemeProvider>
</GestureHandlerRootView>
);
}
BloomDialogProvider powers the imperative alert() helper — mount it once near the app root.
Declarative (the 90% case)
import { Dialog, useDialogControl } from '@oxyhq/bloom';
function SignOutButton() {
const control = useDialogControl();
return (
<>
<Button onPress={() => control.open()}>Sign out</Button>
<Dialog
control={control}
title="Sign out?"
description="You'll need to enter your password to sign in again."
actions={[
{ label: 'Sign out', color: 'destructive', onPress: doSignOut },
{ label: 'Cancel', color: 'cancel' },
]}
/>
</>
);
}
Controlled open state
<Dialog
placement="center"
open={isOpen}
onClose={() => setIsOpen(false)}
title="Confirm?"
actions={[{ label: 'OK', onPress: handleOk }, { label: 'Cancel', color: 'cancel' }]}
/>
Side-sheet (drawer)
// Fixed left side-sheet
<Dialog placement="left" control={control} title="Filters">
<FilterPanel />
</Dialog>
// Responsive: bottom-sheet on mobile, left drawer on desktop
<Dialog placement={{ base: 'bottom', md: 'left' }} control={control} title="Filters">
<FilterPanel />
</Dialog>
Custom content
Provide any JSX as children. Combine with title to keep a consistent header. Set contentPadding={0} when the children own their own insets.
<Dialog control={control} title="Pick a tag" contentPadding={0}>
<YourCustomBody />
</Dialog>
Pure custom
Drop the declarative props entirely — children owns every pixel.
<Dialog control={control}>
<YourEntirelyCustomLayout />
</Dialog>
Props
control?— fromuseDialogControl(). Omit when using controlledopeninstead.open?— controlled open state. When provided, wins overcontrol.placement?—'center' | 'left' | 'right' | 'bottom'or a responsive map{ base; sm?; md?; lg?; xl? }. Default:'center'. Breakpoints: sm 640 / md 768 / lg 1024 / xl 1280 px.title?: string— header text.description?: string— supporting copy rendered below the title.actions?: DialogAction[]— confirmation buttons. Each action:label: stringcolor?: 'default' | 'cancel' | 'destructive'— defaults to'default'.onPress?: (e) => void— invoked after the dialog finishes closing.disabled?: booleanshouldCloseOnPress?: boolean— defaults totrue. Setfalsewhile an async action is in flight.testID?: string
children?: React.ReactNode— custom content rendered after the description.onClose?: () => void— fires after the dialog has finished closing. In controlled mode, the host must flipopentofalse.contentPadding?: number— inner padding of the dialog body. Default20. Set0for custom children that own their own insets.width?: number— side-sheet width (px). Default460.maxWidth?: number— centered-card max width (px). Default480.maxHeightRatio?: number— bottom-sheet height as a fraction of viewport height. Default0.9.inset?: { top?; bottom?; left?; right? }— side-sheet inset (px) from the overlay container edges.showHandle?: boolean— show drag handle in bottom-sheet mode. Defaulttrue.dismissOnBackdrop?: boolean— tap backdrop to dismiss. Defaulttrue.panelStyle? / panelClassName?— style/class for the panel surface.containerStyle? / containerClassName?— style/class for the root overlay (e.g. rail offset, theme-var scope).label?: string— accessibility label.testID?: string
Web setup
Inject the CSS animations into your global styles once:
import { BLOOM_DIALOG_CSS } from '@oxyhq/bloom/dialog';
// In your HTML head or global CSS file:
<style>{BLOOM_DIALOG_CSS}</style>
toast
Passive notifications, sonner under the hood, themed by bloom.
import { toast } from '@oxyhq/bloom';
import { ToastOutlet } from '@oxyhq/bloom/toast';
// Mount once near the app root, inside BloomThemeProvider:
<ToastOutlet />
// Anywhere in your app:
toast('Saved');
toast.success('Profile updated');
toast.error('Network error', { duration: 5000 });
toast.warning('Please verify your email');
toast.info('A new version is available');
toast.dismiss(id);
toast(content, options?) accepts strings or React elements. options.type ('default' | 'success' | 'error' | 'warning' | 'info') is overridden by the typed helpers above.
alert()
Imperative one-shot confirmation dialogs, matching React Native's Alert.alert(title, message?, buttons?) signature. Calls are queued and rendered through BloomDialogProvider — you can call alert() from anywhere, including before the provider mounts (alerts queue and drain on subscribe).
import { alert } from '@oxyhq/bloom';
alert('Sign out?', 'Are you sure you want to sign out of this device?', [
{ text: 'Cancel', style: 'cancel' },
{ text: 'Sign out', style: 'destructive', onPress: doSignOut },
]);
// Single OK button (default when no buttons passed):
alert('Saved');
Each button:
text: string— required label.style?: 'default' | 'cancel' | 'destructive'— defaults to'default'.onPress?: () => void— fires after the dialog finishes closing.
BottomSheet
A standalone, draggable bottom sheet built on React Native Modal + react-native-reanimated + react-native-gesture-handler. Not based on @gorhom/bottom-sheet, so it does not require BottomSheetModalProvider. Use it when the compound Dialog API doesn't fit, when you want to avoid the Gorhom dependency, or when you need direct control over scroll, keyboard handling, or detached presentation.
import { useRef } from 'react';
import { BottomSheet, type BottomSheetRef } from '@oxyhq/bloom/bottom-sheet';
function Example() {
const sheetRef = useRef<BottomSheetRef>(null);
return (
<>
<Button onPress={() => sheetRef.current?.present()}>Open</Button>
<BottomSheet ref={sheetRef} onDismiss={() => console.log('dismissed')}>
<Text>Sheet content</Text>
</BottomSheet>
</>
);
}
BottomSheetRef methods: present(), dismiss(), close(), expand(), collapse(), scrollTo(y, animated?).
BottomSheetProps:
childrenonDismiss?: () => voidenablePanDownToClose?: boolean— defaults totrue.enableHandlePanningGesture?: boolean— defaults totrue.onDismissAttempt?: () => boolean— returnfalseto veto a dismiss attempt.detached?: boolean— whentrue, the sheet floats with horizontal margins and rounded corners on all sides; whenfalse, it's flush to the bottom edges with rounded top corners only.showHandle?: boolean— defaults totrue. Toggles the drag handle pill at the top of the sheet.backdropOpacity?: number— opacity (0–1) of the dimming backdrop once fully visible. Defaults to0.5. Use a higher value (e.g.0.7) when stacking a sheet over another sheet.backgroundComponent?— custom background renderer.backdropComponent?— custom backdrop renderer.style?scrollable?: boolean— defaults totrue. Whenfalse, renderschildrendirectly without the internalAnimated.ScrollViewwrapper. Required when the sheet's content owns its own scrolling primitive (e.g.FlatList,SectionList, or anyVirtualizedList) — nesting a virtualized list inside the internal ScrollView breaks windowing and triggers a React Native warning. Combine withmanualActivationso the handle stays draggable while the inner list owns the scroll.manualActivation?: boolean— defaults tofalse. Whentrue, the body pan uses RNGH'smanualActivationand only activates when (a) the inner ScrollView is at the top AND (b) the user has moved their finger downward by > 8dp. This is the@gorhom/bottom-sheetcoordination model — recommended for sheets that contain a scrolling region on Android (the legacy always-active pan can steal vertical events from the inner scroller). Enabling this also gives the drag handle its own dedicated, unconditionally-active gesture so users can always grab the handle even mid-scroll.dynamicBackdrop?: boolean— defaults tofalse. Whentrue, the backdrop dims proportionally to drag distance — fades from fullbackdropOpacity(sheet at rest) to 30% as the sheet is pulled down 40% of the screen height. This is the iOS Photos / iMessage drag-to-dismiss look. The basebackdropOpacitystill controls the resting dim.handleComponent?: () => React.ReactNode— custom drag-handle renderer. When provided (andshowHandleistrue), replaces the default 36×5 pill. InmanualActivationmode the rendered handle sits inside the dedicated handle hit-area and gesture detector so it remains unconditionally draggable.
Pattern: sheet with a FlatList inside. Use scrollable={false} so the BottomSheet doesn't wrap the list in its own ScrollView, plus manualActivation so the drag handle remains the dedicated drag-to-dismiss surface while the list owns vertical scroll:
<BottomSheet ref={sheetRef} scrollable={false} manualActivation>
<FlatList data={items} renderItem={renderItem} />
</BottomSheet>
Pattern: iOS Photos-style backdrop dim. Combine manualActivation (so the inner photo grid keeps scroll ownership) with dynamicBackdrop (so the overlay fades as the user pulls down):
<BottomSheet
ref={sheetRef}
scrollable={false}
manualActivation
dynamicBackdrop
backdropOpacity={0.85}
>
<PhotoGrid />
</BottomSheet>
Button
import { Button, PrimaryButton, SecondaryButton, IconButton, GhostButton, TextButton } from '@oxyhq/bloom/button';
<Button variant="primary" size="large" onPress={handlePress}>
Save
</Button>
<IconButton icon={<TrashIcon />} onPress={handleDelete} />
<SecondaryButton disabled>Cancel</SecondaryButton>
Variants: primary, secondary, icon, ghost, text. Sizes: small, medium, large.
GroupedButtons
iOS-settings-style grouped action list.
import { GroupedButtons } from '@oxyhq/bloom/grouped-buttons';
<GroupedButtons>
<GroupedButtons.Item label="Edit Profile" icon={<EditIcon />} onPress={handleEdit} />
<GroupedButtons.Item label="Settings" onPress={handleSettings} />
<GroupedButtons.Item label="Delete Account" destructive onPress={handleDelete} />
</GroupedButtons>
Divider
import { Divider } from '@oxyhq/bloom/divider';
<Divider />
<Divider spacing={16} color="#ccc" />
<Divider vertical />
RadioIndicator
import { RadioIndicator } from '@oxyhq/bloom/radio-indicator';
<RadioIndicator selected={isSelected} />
<RadioIndicator selected={true} size={24} selectedColor="#007AFF" />
Avatar
Supports circle and squircle shapes. Squircle requires react-native-svg.
import { Avatar } from '@oxyhq/bloom/avatar';
<Avatar uri="https://example.com/photo.jpg" size={48} />
<Avatar uri={userPhoto} shape="squircle" verified verifiedIcon={<BadgeIcon />} />
<Avatar fallbackSource={require('./default.png')} />
Loading
4 variants: spinner, top (animated collapse/expand), skeleton, inline.
import { Loading } from '@oxyhq/bloom/loading';
<Loading />
<Loading variant="spinner" text="Loading..." />
<Loading variant="top" showLoading={isRefreshing} />
<Loading variant="skeleton" lines={4} />
<Loading variant="inline" text="Saving..." />
Animation is platform-specific but the API and visuals are identical. On native, the spinner (used by spinner/inline) and the top collapse/expand are driven by react-native-reanimated (with react-native-svg for the spinner blades when available). On web, Loading resolves to a CSS-animated fork — a @keyframes rotation for the spinner and CSS transitions for the top variant — so it imports neither reanimated nor SVG and works in any web bundler (Vite, webpack, Metro-web) with no extra peers.
Collapsible
import { Collapsible } from '@oxyhq/bloom/collapsible';
<Collapsible title="Advanced Options" defaultOpen={false}>
<Text>Hidden content here</Text>
</Collapsible>
ErrorBoundary
import { ErrorBoundary } from '@oxyhq/bloom/error-boundary';
<ErrorBoundary onError={(error) => logError(error)}>
<App />
</ErrorBoundary>
<ErrorBoundary
title="Oops!"
message="Something broke."
retryLabel="Retry"
fallback={<CustomFallback />}
>
<RiskyComponent />
</ErrorBoundary>
PromptInput
AI chat input with attachments, fullscreen expand, and submit/stop control.
import {
PromptInput,
PromptInputTextarea,
PromptInputActions,
PromptInputAttachments,
PromptInputSubmitButton,
} from '@oxyhq/bloom/prompt-input';
// Simple mode — renders built-in layout
<PromptInput
value={text}
onValueChange={setText}
onSubmit={handleSend}
isLoading={isGenerating}
onStop={handleStop}
placeholder="Ask anything..."
/>
// Compound mode — full control over layout
<PromptInput value={text} onValueChange={setText} onSubmit={handleSend}>
<PromptInputAttachments />
<PromptInputTextarea placeholder="Type a message..." />
<PromptInputActions>
<MyAddButton />
<PromptInputSubmitButton isLoading={isGenerating} onStop={handleStop} />
</PromptInputActions>
</PromptInput>
Sub-path exports
import { BloomThemeProvider, useTheme } from '@oxyhq/bloom/theme';
import { Dialog, BloomDialogProvider, alert, useDialogControl } from '@oxyhq/bloom/dialog';
import { BottomSheet, type BottomSheetRef } from '@oxyhq/bloom/bottom-sheet';
import { toast, ToastOutlet } from '@oxyhq/bloom/toast';
import { Button, IconButton } from '@oxyhq/bloom/button';
import { GroupedButtons } from '@oxyhq/bloom/grouped-buttons';
import { Divider } from '@oxyhq/bloom/divider';
import { RadioIndicator } from '@oxyhq/bloom/radio-indicator';
import { Avatar } from '@oxyhq/bloom/avatar';
import { Loading } from '@oxyhq/bloom/loading';
import { Collapsible } from '@oxyhq/bloom/collapsible';
import { ErrorBoundary } from '@oxyhq/bloom/error-boundary';
import { PromptInput, PromptInputTextarea } from '@oxyhq/bloom/prompt-input';
Development
bun install
bun run build # react-native-builder-bob
bun run typescript # type-check
bun run test # jest
bun run clean # remove lib/
License
MIT