# BNA UI — complete documentation > BNA UI is a collection of accessible, customisable components for React Native > and Expo, distributed as source code rather than as a package. It is not a web > library: components render through `react-native`, so there is no DOM, no > Tailwind and no Radix. `npx bna-ui add ` copies a component and its > dependency closure into your project, where you own and edit it. Built with > TypeScript, Expo SDK 57 and React Native 0.86, with an iOS/Android/web theme > system, 18 chart components, and Convex, Supabase and Firebase starters for > backend and auth. Every page from https://ui.ahmedbna.com/docs, inlined. Each section below is also available on its own by appending `.md` to its documentation URL. ## Conventions - Components are copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. These specifiers are part of the contract — component source imports its dependencies by those exact paths. - Colours come from the `useColor` hook, which reads the active theme. Do not hardcode hex values. - Sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Every component is a plain React Native component: style with `StyleSheet` objects and the `style` prop, never `className`. # About > BNA UI's philosophy, origin, and the open source projects and community it's built on. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/about - Markdown: https://ui.ahmedbna.com/docs/about.md --- ## About BNA UI is a mobile-first component library inspired by [shadcn/ui](https://ui.shadcn.com), built specifically for React Native and Expo applications. Our mission is to provide developers with beautiful, accessible, and performant components that work seamlessly across iOS, Android, and web platforms. ## Philosophy We believe that mobile development should be as elegant and efficient as web development. BNA UI bridges the gap between beautiful design and practical mobile implementation, giving developers the tools they need to create outstanding mobile experiences without compromising on performance or accessibility. ## Credits - [shadcn/ui](https://ui.shadcn.com) - For the inspiration and design philosophy that powers this project. - [React Native](https://reactnative.dev) - The foundation that makes cross-platform mobile development possible. - [Expo](https://expo.dev) - For the incredible developer experience and tooling ecosystem. - [React Native Reanimated](https://docs.swmansion.com/react-native-reanimated) - For smooth, performant animations. - [React Native Gesture Handler](https://docs.swmansion.com/react-native-gesture-handler) - For native gesture recognition. - [Expo Haptics](https://docs.expo.dev/versions/latest/sdk/haptics) - For tactile feedback that enhances user experience. - [Lucide React Native](https://lucide.dev) - For beautiful, consistent icons. ## Community BNA UI is built by and for the React Native community. We're grateful for the contributions, feedback, and support from developers around the world who are building amazing mobile applications. ## License MIT © BNA UI Contributors --- _Built with ❤️ for the React Native community_ # Introduction > BNA UI is a set of beautifully-designed, accessible React Native components. Built for Expo and React Native. Open Source. Open Code. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs - Markdown: https://ui.ahmedbna.com/docs.md --- You know how most traditional React Native component libraries work: you install a package from NPM, import the components, and use them in your app. This approach works well until you need to customize a component to fit your mobile design system or require one that isn't included in the library. **Often, you end up wrapping library components, writing workarounds to override styles, or mixing components from different libraries with incompatible APIs and platform-specific quirks.** This is what BNA UI aims to solve. It is built around the following principles: - **Open Code:** The top layer of your component code is open for modification. - **Mobile-First:** Every component is designed specifically for React Native and Expo. - **Cross-Platform:** Components work seamlessly across iOS, Android, and web. - **Composition:** Every component uses a common, composable interface, making them predictable. - **Distribution:** A flat-file schema and command-line tool make it easy to distribute components. - **Beautiful Defaults:** Carefully chosen default styles optimized for mobile interfaces. - **AI-Ready:** Open code for LLMs to read, understand, and improve. ## Open Code BNA UI hands you the actual component code. You have full control to customize and extend the components to your mobile needs. This means: - **Full Transparency:** You see exactly how each component is built for React Native. - **Easy Customization:** Modify any part of a component to fit your mobile design and functionality requirements. - **Platform Flexibility:** Adapt components for specific iOS or Android behaviors when needed. - **AI Integration:** Access to the code makes it straightforward for LLMs to read, understand, and even improve your mobile components. _In a typical React Native library, if you need to change a button's touch behavior or add haptic feedback, you have to override styles or wrap the component. With BNA UI, you simply edit the button code directly._ **How do I pull upstream updates in an Open Code approach?** BNA UI follows a headless component architecture. This means the core of your app can receive fixes by updating your dependencies, for instance, react-native-reanimated, expo-haptics, or react-native-gesture-handler. The topmost layer, i.e., the one closest to your mobile design system, is not coupled with the implementation of the library. It stays open for modification while leveraging the power of Expo and React Native primitives. ## Mobile-First Design BNA UI is built specifically for mobile interfaces and React Native development: - **Touch Optimized:** All components are designed with touch interactions in mind, including proper touch targets and gestures. - **Performance Focused:** Components are optimized for mobile performance with minimal re-renders and efficient animations. - **Native Feel:** Components respect platform conventions and feel native on both iOS and Android. - **Responsive:** Components adapt to different screen sizes and orientations seamlessly. ## Cross-Platform Excellence Every component in BNA UI works consistently across platforms while respecting platform-specific conventions: - **iOS & Android:** Components automatically adapt to platform design guidelines. - **Expo Web:** Full compatibility with Expo's web target for universal apps. - **Consistent API:** Write once, works everywhere with platform-appropriate styling. ## Composition Every component in BNA UI shares a common, composable interface. **If a component does not exist, we bring it in, make it composable, and adjust its style to match and work with the rest of the mobile design system.** _A shared, composable interface means it's predictable for both your team and LLMs. You are not learning different APIs for every new component. Even for third-party React Native ones._ ## Distribution BNA UI is also a code distribution system designed for React Native and Expo projects. It defines a schema for mobile components and a CLI to distribute them. - **Schema:** A flat-file structure that defines the mobile components, their dependencies, and platform-specific properties. - **CLI:** A command-line tool to distribute and install components across React Native projects with Expo support. - **Expo Integration:** Seamless integration with Expo CLI and development workflow. _You can use the schema to distribute your mobile components to other projects or have AI generate completely new React Native components based on existing schema._ ## Beautiful Defaults BNA UI comes with a large collection of mobile components that have carefully chosen default styles. They are designed to look good on mobile devices and to work well together as a consistent system: - **Mobile-Optimized:** Your UI has a clean and modern mobile look without extra work. - **Platform Appropriate:** Components automatically follow iOS and Android design guidelines. - **Unified Design:** Components naturally fit with one another across all platforms. - **Touch-Friendly:** All interactive elements meet accessibility guidelines for touch targets. - **Easily Customizable:** If you want to change something, it's simple to override and extend the defaults. ## AI-Ready The design of BNA UI makes it easy for AI tools to work with your React Native code. Its open code and consistent API allow AI models to read, understand, and even generate new mobile components. _An AI model can learn how your mobile components work and suggest improvements or even create new React Native components that integrate with your existing mobile design system._ # CLI > Use the BNA UI CLI to add components to your React Native and Expo projects. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/cli - Markdown: https://ui.ahmedbna.com/docs/cli.md --- ## init Use the `init` command to initialize a new BNA project with configuration and dependencies. The `init` command creates a new project, installs dependencies, adds utils, hooks, themes and sets up the component structure for your mobile project. ```bash npx bna-ui init [project-name] ``` ### Options ```bash Usage: bna-ui init [options] [project-name] Scaffold a new Expo app with BNA UI set up Arguments: project-name Name of the project, or `.` for the current directory Options: --npm Install with npm --yarn Install with yarn --pnpm Install with pnpm --bun Install with bun --verbose Print the full error stack on failure --skip-install Create the project without installing dependencies -h, --help display help for command Examples $ bna-ui init my-app $ bna-ui init . # scaffold into the current directory $ bna-ui init my-app --pnpm --skip-install ``` ## convex Use the `convex` command to initialize a new BNA project with Convex backend integration. The `convex` command creates a new project with Convex configured, installs dependencies, and sets up the backend structure. ```bash npx bna-ui convex [project-name] ``` By default this scaffolds the full authentication setup — Google, Apple, password and email OTP sign-in. Pass `--no-auth` for a Convex backend with no sign-in at all: a schema, a demo query, and nothing to delete before you start. ```bash npx bna-ui convex [project-name] --no-auth ``` See [Expo + Convex](/docs/installation/convex) and [Expo + Convex + Auth](/docs/installation/convex-auth) for what each one gives you. ### Options ```bash Usage: bna-ui convex [options] [project-name] Scaffold a new Expo app with a Convex backend Arguments: project-name Name of the project Options: --npm Install with npm --yarn Install with yarn --pnpm Install with pnpm --bun Install with bun --verbose Print the full error stack on failure --skip-install Create the project without installing dependencies --skip-convex Skip provisioning the Convex backend --no-auth Scaffold Convex without authentication -h, --help display help for command Examples $ bna-ui convex my-app $ bna-ui convex my-app --no-auth $ bna-ui convex my-app --skip-convex # wire it up yourself later ``` ## supabase Use the `supabase` command to initialize a new BNA project with a Supabase backend — Postgres, row level security, realtime, storage and edge functions. ```bash npx bna-ui supabase [project-name] ``` By default this scaffolds the full authentication setup — password, magic links, email OTP, Google, Apple and GitHub. Pass `--no-auth` for a backend with no sign-in: migrations, a live query, storage and an edge function. ```bash npx bna-ui supabase [project-name] --no-auth ``` After copying files it asks for your project URL and publishable key, writes them to `.env.local`, and then — only if the [Supabase CLI](https://supabase.com/docs/guides/local-development) is on your `PATH` — links the project, applies the migrations and generates `lib/database.types.ts`. If it is not installed, those commands are printed as next steps instead. Nothing fails either way. See [Expo + Supabase](/docs/installation/supabase) and [Expo + Supabase + Auth](/docs/installation/supabase-auth) for what each one gives you. ### Options ```bash Usage: bna-ui supabase [options] [project-name] Scaffold a new Expo app with a Supabase backend Arguments: project-name Name of the project Options: --npm Install with npm --yarn Install with yarn --pnpm Install with pnpm --bun Install with bun --verbose Print the full error stack on failure --skip-install Create the project without installing dependencies --skip-supabase Skip linking the project and applying migrations --no-auth Scaffold Supabase without authentication -h, --help display help for command Examples $ bna-ui supabase my-app $ bna-ui supabase my-app --no-auth $ bna-ui supabase my-app --skip-supabase ``` ## firebase Use the `firebase` command to initialize a new BNA project with a Firebase backend — Cloud Firestore, Cloud Storage, and security rules with tests that execute them. ```bash npx bna-ui firebase [project-name] ``` By default this scaffolds the full authentication setup — email and password, Google and Apple, onboarding and a profile. Pass `--no-auth` for a backend with no sign-in: a live task list, storage uploads with progress, and open rules. ```bash npx bna-ui firebase [project-name] --no-auth ``` After copying files it asks for your Firebase web config — deriving the auth domain and storage bucket from the project ID as defaults — writes `.env.local`, sets the default project in `.firebaserc`, and then, only if [firebase-tools](https://firebase.google.com/docs/cli) is on your `PATH`, selects the project and deploys the rules and indexes. If it is not installed, those commands are printed as next steps instead. Nothing fails either way. > The CLI cannot create a project for you. You need one with a **Web** app added > to it, plus Firestore and Storage enabled from the console's Build menu. See [Expo + Firebase](/docs/installation/firebase) and [Expo + Firebase + Auth](/docs/installation/firebase-auth) for what each one gives you. ### Options ```bash Usage: bna-ui firebase [options] [project-name] Scaffold a new Expo app with a Firebase backend Arguments: project-name Name of the project Options: --npm Install with npm --yarn Install with yarn --pnpm Install with pnpm --bun Install with bun --verbose Print the full error stack on failure --skip-install Create the project without installing dependencies --skip-firebase Skip collecting the config and deploying the rules --no-auth Scaffold Firebase without authentication -h, --help display help for command Examples $ bna-ui firebase my-app $ bna-ui firebase my-app --no-auth $ bna-ui firebase my-app --skip-firebase ``` ## add Use the `add` command to add components and dependencies to your React Native or Expo project. ```bash npx bna-ui add [component] ``` ### Options ```bash Usage: bna-ui add [options] [components...] Add components to an existing project Arguments: components Component names. Omit to pick interactively. Options: --npm Install with npm --yarn Install with yarn --pnpm Install with pnpm --bun Install with bun --verbose Print the full error stack on failure --overwrite Replace files that already exist --dry-run Show what would be written, and write nothing -y, --yes Skip prompts, keeping any existing files --registry Registry to fetch from -h, --help display help for command Examples $ bna-ui add button $ bna-ui add button input card $ bna-ui add # browse and pick $ bna-ui add button --dry-run $ bna-ui add button --registry http://localhost:3000/r ``` ### Where components come from `add` fetches component source from `https://ui.ahmedbna.com/r` and writes it into your project. Responses are cached under `~/.cache/bna-ui` and revalidated with ETags, so repeat installs are fast and work offline. Point it somewhere else with the `--registry` flag or the `BNA_UI_REGISTRY` environment variable: ```bash npx bna-ui add button --registry http://localhost:3000/r ``` Adding a component brings its whole dependency chain — `button` also installs the `text`, `icon` and `spinner` it composes, the hooks and theme files they import, and any npm packages they need. ## list Every component, chart, hook and theme file the registry ships. ```bash npx bna-ui list npx bna-ui list --type chart ``` ### Options ```bash Usage: bna-ui list|ls [options] List every component, chart, hook and theme file Options: --type Narrow to ui, chart, hook or theme --json Emit as JSON, for scripts and agents --registry Registry to read from --verbose Print the full error stack on failure -h, --help display help for command Examples $ bna-ui list $ bna-ui list --type chart $ bna-ui list --json ``` ## search Find something without knowing its exact name. ```bash npx bna-ui search chart ``` ### Options ```bash Usage: bna-ui search [options] Search the registry by name or description Arguments: query Search term, e.g. "chart" or "date" Options: --json Emit as JSON, for scripts and agents --registry Registry to read from --verbose Print the full error stack on failure -h, --help display help for command Examples $ bna-ui search chart $ bna-ui search "date picker" ``` ## info A component's description, props, install command, source and examples — the same bundle the MCP server serves. `--json` makes it machine-readable. ```bash npx bna-ui info button npx bna-ui info button --json ``` ### Options ```bash Usage: bna-ui info [options] Print a component's props, source and examples Arguments: component Component name, e.g. button Options: --json Emit the full bundle as JSON, for scripts and agents --registry Registry to fetch from --verbose Print the full error stack on failure -h, --help display help for command Examples $ bna-ui info button $ bna-ui info button --json | jq .meta ``` ## mcp Runs an MCP server over stdio so an AI assistant can browse and read the registry itself. See [MCP](/docs/mcp) for editor configuration. ```bash claude mcp add bna-ui -- npx -y bna-ui mcp ``` The server ships as a separate package, `@bna-ui/mcp`, and `bna-ui mcp` hands off to it. That keeps the MCP SDK — which pulls in HTTP and SSE transport dependencies this server never uses — out of every `npx bna-ui add`. ## components.json `init` writes one. It is optional: every command works without it, on the same defaults. It exists so you stop repeating yourself. ```json title="components.json" { "registry": "https://ui.ahmedbna.com/r", "aliases": { "components": "ui-kit" }, "packageManager": "pnpm" } ``` | Key | Effect | | ---------------- | --------------------------------------------------------------- | | `registry` | Where `add`, `list`, `search` and `info` fetch from | | `aliases` | Where each kind of file is written, relative to the `@/` root | | `baseDir` | Overrides where `@/` points, when the tsconfig reading is wrong | | `packageManager` | Which manager installs npm dependencies | ### Where files land `add` writes relative to whatever your `tsconfig.json` maps `@/*` onto — the same alias every installed file imports through. Nothing to configure: | `"@/*"` in your tsconfig | `add button` writes | | ------------------------ | ------------------------------ | | `["./*"]` | `components/ui/button.tsx` | | `["./src/*"]` | `src/components/ui/button.tsx` | `baseUrl` is honoured, and `jsconfig.json` works the same way. Set `baseDir` to override the detection — useful when the alias is declared in a base config you `extends` rather than in the project's own tsconfig. ### Aliases `aliases` moves a kind of file to a different directory _inside_ `@/`, and `add` rewrites the matching import specifiers in the source it copies so everything still resolves. With the config above, `add button` writes `ui-kit/ui/button.tsx` and its imports read `@/ui-kit/ui/text`. Only the leading segment is remapped — the `ui/` beneath it is part of the registry's own layout. Note these are relative to the `@/` root, not to the project root. In an app whose `@/*` maps to `./src/*`, components already land under `src/` — writing `"components": "src/components"` would nest them twice, so `add` ignores the redundant prefix and tells you to drop it. ## Examples ### Initialize a new project ```bash npx bna-ui init my-app ``` ### Initialize with Convex backend ```bash npx bna-ui convex my-convex-app ``` ### Initialize with Supabase backend ```bash npx bna-ui supabase my-supabase-app ``` ### Initialize with Firebase backend ```bash npx bna-ui firebase my-firebase-app ``` ### Initialize using specific package manager ```bash npx bna-ui init my-app --pnpm ``` ### Skip package installation ```bash npx bna-ui init my-app --skip-install ``` ### Add a button component ```bash npx bna-ui add button ``` ### Add multiple components ```bash npx bna-ui add button input card ``` ### Add component with overwrite ```bash npx bna-ui add button --overwrite ``` ### Preview what would be installed ```bash npx bna-ui add button --dry-run ``` ### Add components without confirmation ```bash npx bna-ui add button input --yes ``` ## Package managers Pass a flag to force one: - `--npm` - `--yarn` - `--pnpm` - `--bun` Without a flag the CLI works it out, in this order: 1. `packageManager` in your `components.json`. 2. How you invoked it — `npx`, `pnpm dlx`, `bunx`. 3. Your project's lockfile or its `packageManager` field. 4. npm. The scaffold commands print which one they picked. ### Why scaffolds pin a linker Metro and React Native's autolinking both read `node_modules` off disk, so Expo needs a real, flat one. Two package managers do not give it one by default, and scaffolds ship the config that fixes each. ### yarn Yarn 2+ installs [Plug'n'Play](https://yarnpkg.com/features/pnp) by default — a `.pnp.cjs` resolver and no `node_modules` directory at all, which Metro cannot read. Scaffolds ship: ```yaml title=".yarnrc.yml" nodeLinker: node-modules ``` Yarn 1.x is flat already and ignores this file. ### pnpm Scaffolds ship an `.npmrc` and a `pnpm-workspace.yaml` that both pin pnpm to the hoisted linker: ```ini title=".npmrc" node-linker=hoisted ``` ```yaml title="pnpm-workspace.yaml" nodeLinker: hoisted ``` Expo needs a flat `node_modules` — Metro resolves its own empty module out of `metro-config`'s require paths, and React Native's autolinking walks `node_modules` by hand. Under pnpm's default isolated layout `expo start` fails with `Unable to resolve module …/metro-runtime/src/modules/empty-module.js`. Both files ship because pnpm 11 reads settings only from `pnpm-workspace.yaml` while pnpm 10.15 and older read only `.npmrc`. If you have a project scaffolded before this landed, add both files and reinstall. Clear Metro's cache on the first run — switching an existing project from the isolated layout to the hoisted one turns `node_modules/react-native` from a symlink into a real directory, and a warm cache fails on the change with `TreeFS: Could not add directory node_modules/react-native`: ```bash rm -rf node_modules pnpm-lock.yaml pnpm install npx expo start --clear ``` # Theming > BNA UI theming system for your React Native UI components using TypeScript constants and color utilities. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/theming - Markdown: https://ui.ahmedbna.com/docs/theming.md --- ## Overview Your theming system provides a structured approach to managing colors, dimensions, and styling across your React Native application. The theme is built around light and dark color schemes with support for semantic color naming. ## Color System ### Light Theme ```typescript const lightColors = { // Base colors background: '#FFFFFF', foreground: '#000000', // Card colors card: '#F2F2F7', cardForeground: '#000000', // Primary colors primary: '#18181b', primaryForeground: '#FFFFFF', // Secondary colors secondary: '#F2F2F7', secondaryForeground: '#18181b', // System colors destructive: '#ef4444', destructiveForeground: '#FFFFFF', // Interactive colors blue: '#007AFF', green: '#34C759', red: '#FF3B30', orange: '#FF9500', yellow: '#FFCC00', pink: '#FF2D92', purple: '#AF52DE', teal: '#5AC8FA', indigo: '#5856D6', }; ``` ### Dark Theme ```typescript const darkColors = { // Base colors background: '#000000', foreground: '#FFFFFF', // Card colors card: '#1C1C1E', cardForeground: '#FFFFFF', // Primary colors primary: '#e4e4e7', primaryForeground: '#18181b', // Secondary colors secondary: '#1C1C1E', secondaryForeground: '#FFFFFF', // System colors destructive: '#dc2626', destructiveForeground: '#FFFFFF', // Interactive colors (adapted for dark mode) blue: '#0A84FF', green: '#30D158', red: '#FF453A', orange: '#FF9F0A', yellow: '#FFD60A', pink: '#FF375F', purple: '#BF5AF2', teal: '#64D2FF', indigo: '#5E5CE6', }; ``` ## Global Constants ### Dimensions ```typescript export const HEIGHT = 48; // Standard component height export const FONT_SIZE = 17; // Base font size export const BORDER_RADIUS = 26; // Standard border radius export const CORNERS = 999; // Fully rounded corners ``` ### Usage Examples ```typescript // Using in StyleSheet const styles = StyleSheet.create({ button: { height: HEIGHT, borderRadius: BORDER_RADIUS, backgroundColor: Colors.light.primary, }, roundedButton: { height: HEIGHT, borderRadius: CORNERS, backgroundColor: Colors.light.blue, }, text: { fontSize: FONT_SIZE, color: Colors.light.foreground, }, }); ``` ## Color Conventions ### Semantic Naming The color system follows a semantic naming convention where each color has a corresponding foreground color: - `background` / `foreground` - Main app background and text - `card` / `cardForeground` - Card backgrounds and text - `primary` / `primaryForeground` - Primary actions and buttons - `secondary` / `secondaryForeground` - Secondary actions - `destructive` / `destructiveForeground` - Error states and dangerous actions ### iOS System Colors The theme includes iOS system colors that automatically adapt to the user's appearance settings: - `blue` - Default buttons, links, selected tabs - `green` - Success states, completed tasks - `red` - Delete buttons, error states - `orange` - Warning states - `yellow` - Highlights and accents - `pink` - Creative accents - `purple` - Feature highlights - `teal` - Communication features - `indigo` - System features ## Theme Context ### Setting Up Theme Provider ```typescript import { Colors } from './colors'; import { useColorScheme } from 'react-native'; export const ThemeProvider = ({ children }: { children: React.ReactNode }) => { const colorScheme = useColorScheme(); // Create custom themes that use your Colors const customLightTheme = { ...DefaultTheme, colors: { ...DefaultTheme.colors, primary: Colors.light.primary, background: Colors.light.background, card: Colors.light.card, text: Colors.light.text, border: Colors.light.border, notification: Colors.light.red, }, }; const customDarkTheme = { ...DarkTheme, colors: { ...DarkTheme.colors, primary: Colors.dark.primary, background: Colors.dark.background, card: Colors.dark.card, text: Colors.dark.text, border: Colors.dark.border, notification: Colors.dark.red, }, }; return ( {children} ); }; ``` ### Using Theme in Components ```typescript const Button = ({ children, variant = 'primary' }: ButtonProps) => { const color = useColor(variant); const buttonStyle = { height: HEIGHT, borderRadius: BORDER_RADIUS, backgroundColor: color, justifyContent: 'center' as const, alignItems: 'center' as const, }; const textStyle = { color: colors[`${variant}Foreground`], fontSize: FONT_SIZE, fontWeight: '600' as const, }; return ( {children} ); }; ``` ## Component Examples ### Card Component ```typescript const Card = ({ children }: { children: React.ReactNode }) => { const color = useColor('card'); return ( {children} ); }; ``` ## Customization ### Adding New Colors To add new colors to your theme: 1. Add the color to both light and dark color objects: ```typescript const lightColors = { // ... existing colors success: '#34C759', successForeground: '#FFFFFF', }; const darkColors = { // ... existing colors success: '#30D158', successForeground: '#FFFFFF', }; ``` 2. Update the ColorKeys type: ```typescript export type ColorKeys = keyof typeof lightColors; ``` ### Modifying Global Constants Update the global constants to match your design system: ```typescript export const HEIGHT = 44; // Smaller height export const FONT_SIZE = 16; // Smaller font export const BORDER_RADIUS = 12; // Less rounded export const CORNERS = 22; // Moderately rounded ``` ## TypeScript Support The theme system is fully typed with TypeScript: ```typescript // Color keys are typed export type ColorKeys = keyof typeof lightColors; // Theme context is typed export interface ThemeContextType { colors: typeof lightColors; isDark: boolean; } // Component props can use color keys interface ButtonProps { variant?: ColorKeys; children: React.ReactNode; } ``` ## Best Practices 1. **Use semantic colors** - Prefer `primary` over specific colors like `blue` 2. **Consistent spacing** - Use the global constants for consistent dimensions 3. **Automatic dark mode** - Always provide both light and dark variants 4. **Accessibility** - Ensure sufficient contrast between foreground and background colors 5. **Platform consistency** - Use iOS system colors where appropriate This theming system provides a solid foundation for building consistent, accessible, and platform-appropriate React Native applications. # AI & LLMs > Every page and every component is available as Markdown and as structured JSON, so an AI assistant can read this library without scraping it. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/llms - Markdown: https://ui.ahmedbna.com/docs/llms.md --- Everything in these docs exists in a machine-readable form. Nothing here needs an API key, and every endpoint sends `Access-Control-Allow-Origin: *`. ## Any page as Markdown Append `.md` to any documentation URL. ```bash curl https://ui.ahmedbna.com/docs/components/button.md ``` The Markdown is not the page's prose with the components stripped out — it is the page with them **expanded**. `` and `` become real code blocks, so the file contains the complete source of the component and of every example on the page. Each file opens with a header naming the library, its React-Native-not-web constraint, the install command and the import contract, so a model handed one file in isolation has everything it needs. Every page also advertises this in its ``: ```html ``` ## llms.txt ### Index [`/llms.txt`](https://ui.ahmedbna.com/llms.txt) follows the [llmstxt.org](https://llmstxt.org) convention: what this library is, then every page grouped by section with a one-line description, then the conventions that matter and the endpoints below. Start here. ```bash curl https://ui.ahmedbna.com/llms.txt ``` ### Everything [`/llms-full.txt`](https://ui.ahmedbna.com/llms-full.txt) is every page's Markdown concatenated into one file — roughly 2 MB, including the source of every component and every example. Use it when you would rather ingest once than fetch ninety-six times. ```bash curl https://ui.ahmedbna.com/llms-full.txt ``` ## Component bundles `/r/ai/.json` is the one request that answers everything about a component: what it does, its props and variants, a usage snippet, its accessibility notes, its npm and registry dependencies, its source, and every example that uses it. ```bash curl https://ui.ahmedbna.com/r/ai/button.json ``` ```json { "name": "button", "description": "A versatile button component with multiple variants, sizes, and interactive animations.", "docs": "https://ui.ahmedbna.com/docs/components/button", "markdown": "https://ui.ahmedbna.com/docs/components/button.md", "install": { "cli": "npx bna-ui add button", "npm": ["expo-haptics", "..."] }, "framework": { "runtime": "react-native", "framework": "expo" }, "meta": { "types": [], "variants": [], "usage": {}, "accessibility": {} }, "files": [{ "target": "components/ui/button.tsx", "content": "..." }], "examples": [{ "name": "button-variants", "files": [] }] } ``` [`/r/ai/index.json`](https://ui.ahmedbna.com/r/ai/index.json) lists every installable component with its description and bundle URL. ## Install payloads `/r/.json` is what the CLI fetches: the component's source plus the full transitive closure of everything it imports, in dependency order and ready to write to disk. One request installs a component no matter how deep its graph. ```bash curl https://ui.ahmedbna.com/r/button.json ``` Every payload carries a `$schemaVersion`. A CLI that sees a version higher than it understands refuses the payload rather than writing files it cannot interpret. ## What a model needs to know If you are pasting context into an assistant yourself, these are the four things that stop it writing web React: - **This is React Native.** Components render through `react-native`, not the DOM. There are no HTML elements, no Tailwind classes and no Radix primitives. - **Style with objects.** `StyleSheet` and the `style` prop, never `className`. - **Imports are aliases.** Source is copied into your project and resolves through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Those specifiers are part of the contract. - **Colours come from `useColor`.** Never hardcode hex. Sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. Or skip the pasting: every page has a **Copy Page** button and an **Open in…** menu that hands ChatGPT, Claude, Gemini, v0, Copilot, Cursor or Windsurf a prompt with all of the above already in it. # MCP Server > Let Claude Code, Cursor, VS Code and other assistants browse the registry and read component source directly, instead of guessing or scraping. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/mcp - Markdown: https://ui.ahmedbna.com/docs/mcp.md --- `bna-ui mcp` runs a [Model Context Protocol](https://modelcontextprotocol.io) server over the same registry the CLI installs from. Your assistant asks it what exists and reads the real source, rather than recalling a component library from its training data — which, for anything that looks like this one, means web React. > It needs no configuration and no API key. Everything it serves is the public > registry, cached under `~/.cache/bna-ui` with ETags, so repeat calls are free > and it keeps working offline. ## Install ### Claude Code ```bash claude mcp add bna-ui -- npx -y bna-ui mcp ``` Then ask for a component by name — "add a BNA UI button with a loading state" — and Claude will call `get_component` before writing anything. ### Other clients Every MCP client takes the same command. Add this to its config file: ```json { "mcpServers": { "bna-ui": { "command": "npx", "args": ["-y", "bna-ui", "mcp"] } } } ``` - **Cursor** — `.cursor/mcp.json`, or [one-click install](cursor://anysphere.cursor-deeplink/mcp/install?name=bna-ui\&config=eyJjb21tYW5kIjoibnB4IiwiYXJncyI6WyIteSIsImJuYS11aSIsIm1jcCJdfQ%3D%3D) - **VS Code / Copilot** — `.vscode/mcp.json`, or [one-click install](vscode:mcp/install?%7B%22name%22%3A%22bna-ui%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22bna-ui%22%2C%22mcp%22%5D%7D) - **Windsurf** — `~/.codeium/windsurf/mcp_config.json` - **Codex** — `~/.codex/config.toml` ## Tools | Tool | What it answers | | ---------------------- | ------------------------------------------------------------------------------------------------------ | | `list_components` | Everything installable, filterable by component, chart, hook or theme. | | `search_components` | "Which component does X?" — matches names and descriptions. | | `get_component` | Description, props, variants, usage, accessibility notes, dependencies, full source and every example. | | `get_component_source` | Just the source, for when the API is already known. | | `get_install_plan` | The exact `add` command, the npm packages, and the files it will write. Never writes anything itself. | | `get_docs` | Any documentation page as Markdown, with all source expanded inline. | Every component response is prefixed with the React-Native constraint, because a model that read it once at the start of a long session has usually stopped attending to it by the time it writes the JSX. ## Nothing is written to disk `get_install_plan` returns a command; it does not run it. Installing stays an explicit step you or your assistant takes with `npx bna-ui add`, so nothing appears in your project without you asking. ## Without MCP Any agent that can run a command gets the same data: ```bash npx bna-ui info button --json ``` Or fetch it directly — see [AI & LLMs](/docs/llms) for every endpoint. # Rules for agents > Drop-in AGENTS.md, Cursor rules and CLAUDE.md snippets that teach a coding assistant this is React Native, not the web. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/agents - Markdown: https://ui.ahmedbna.com/docs/agents.md --- An assistant that has never seen BNA UI will assume it works like every other component library it has read — web React, Tailwind, Radix. These files correct that before it writes a line. If your assistant supports [MCP](/docs/mcp), install that instead or as well: it reads the live registry rather than a snapshot. ## AGENTS.md The canonical brief, generated from the registry so its component list is never stale. Put it at the root of your project. ```bash curl -o AGENTS.md https://ui.ahmedbna.com/AGENTS.md ``` [Read it](https://ui.ahmedbna.com/AGENTS.md) — it covers the React-Native constraint, the install flow, the `@/` import contract, theming through `useColor`, and every machine-readable endpoint. ## Cursor ```bash mkdir -p .cursor/rules && curl -o .cursor/rules/bna-ui.mdc https://ui.ahmedbna.com/AGENTS.md ``` Add a front-matter block at the top so Cursor applies it to your React Native files: ```text --- description: BNA UI — React Native component library conventions globs: ['**/*.tsx', '**/*.ts'] alwaysApply: false --- ``` ## Claude Code Claude Code reads `AGENTS.md` at the project root automatically. If you keep a `CLAUDE.md` instead, reference it rather than duplicating it: ```md ## UI components This project uses BNA UI (React Native / Expo). See AGENTS.md for the conventions, or run `npx bna-ui info ` for a component's API. Never write `
` or `className` — these components render through react-native. Colours come from the `useColor` hook. ``` ## The short version If you are pasting context by hand, these five lines do most of the work: ```text BNA UI is a React Native / Expo component library, not a web library. Components render through react-native — no DOM, no Tailwind, no Radix. Style with StyleSheet objects and the style prop, never className. Imports resolve through @/components/ui/*, @/hooks/* and @/theme/*. Colours come from the useColor hook; never hardcode hex. ``` Or use the **Copy Page** button at the top of any component page, which puts the whole page — source, props and all — on your clipboard with that context already attached. # Claude Skill > A packaged skill that teaches Claude this library's components, conventions and endpoints — loaded only when a conversation is actually about React Native. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/skills - Markdown: https://ui.ahmedbna.com/docs/skills.md --- A [Skill](https://code.claude.com/docs/en/skills) is a folder Claude loads on demand. The BNA UI skill bundles the component catalogue, the theming and layout conventions, and every machine-readable endpoint, so Claude knows what exists here without being told each time. The loading is progressive: only the trigger description sits in context permanently. The catalogue and conventions are read when a conversation is actually about React Native — so the cost is near zero the rest of the time. ## Install ### Claude Code **1.** Download and unpack it into your skills directory. ```bash curl -L https://ui.ahmedbna.com/skills/bna-ui.zip -o bna-ui.zip \ && unzip -q bna-ui.zip -d ~/.claude/skills && rm bna-ui.zip ``` **2.** Restart Claude Code, or run /doctor to confirm it loaded. Use `.claude/skills` instead of `~/.claude/skills` to scope it to one project. ### Claude apps Download [bna-ui.zip](https://ui.ahmedbna.com/skills/bna-ui.zip) and upload it under **Settings → Capabilities → Skills**. ## What's in it | File | Read when | | --------------------------- | ---------------------------------------------------- | | `SKILL.md` | A conversation touches React Native, Expo or BNA UI. | | `references/catalogue.md` | Claude needs to find the right component. | | `references/conventions.md` | Claude is writing or reviewing component code. | | `references/endpoints.md` | Claude needs live data from the registry. | The catalogue is generated from the registry at build time, so it lists exactly what is installable — it cannot drift. ## Skill, MCP, or rules file? They stack, and each covers a different gap. - **[MCP server](/docs/mcp)** — live data. Claude reads the actual current source and props rather than a snapshot. Best if you install one thing. - **Skill** — conventions and judgement. Knows _when_ to reach for this library and how to use it well, without a network round-trip. - **[Rules file](/docs/agents)** — works in any assistant, including ones that support neither of the above. Together: the skill supplies the conventions, the MCP server supplies the facts. # shadcn > Acknowledging the amazing projects and people that made BNA UI possible. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/shadcn - Markdown: https://ui.ahmedbna.com/docs/shadcn.md --- ## Credits BNA UI stands on the shoulders of giants. This project and its documentation would not exist without the incredible work of the open source community and the visionary developers who came before us. ## Design & Inspiration **[shadcn/ui](https://ui.shadcn.com)** - The cornerstone of modern React component design. BNA UI is deeply inspired by shadcn's elegant approach to component architecture, design tokens, and developer experience. We've adapted these principles for the mobile world while maintaining the same philosophy of beautiful, accessible, and customizable components. ## Documentation This documentation site is built by [shadcn](https://twitter.com/shadcn). The original codebase has been thoughtfully modified and adapted for BNA UI's specific needs, but the foundation, design system, and user experience patterns remain faithful to shadcn's exceptional work. **Original Documentation:** Created by [shadcn](https://github.com/shadcn-ui)\ **Modified for BNA UI by:** [BNA](https://github.com/ahmedbna) ## Core Technologies - **[Next.js](https://nextjs.org)** - The React framework that powers our documentation site - **[React Native](https://reactnative.dev)** - The foundation of our mobile component library - **[Expo](https://expo.dev)** - The platform that makes React Native development delightful ## Animation & Interaction - **[React Native Reanimated](https://docs.swmansion.com/react-native-reanimated)** - Bringing smooth, performant animations to mobile - **[React Native Gesture Handler](https://docs.swmansion.com/react-native-gesture-handler)** - Native gesture recognition for intuitive interactions - **[Expo Haptics](https://docs.expo.dev/versions/latest/sdk/haptics)** - Tactile feedback that makes interfaces feel alive - **[Lucide React Native](https://lucide.dev)** - Beautiful, consistent iconography ## Community & Support - **React Native Community** - For building an ecosystem that enables cross-platform mobile development - **Expo Team** - For continuously improving the developer experience - **shadcn** - For setting the standard for what component libraries can be ## Special Thanks A heartfelt thank you to **[shadcn](https://twitter.com/shadcn)** for creating not just a component library, but a philosophy and approach to building user interfaces that prioritizes developer experience, accessibility, and beautiful design. BNA UI exists because shadcn/ui showed us what's possible. ## License BNA UI is MIT licensed. The original shadcn/ui documentation template is also MIT licensed. --- _Built with gratitude for the open source community_ 🙏 # Installation > Pick the stack you are building on and follow the guide for it — plain Expo, or Expo with a Convex, Supabase or Firebase backend, with or without authentication. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/installation - Markdown: https://ui.ahmedbna.com/docs/installation.md --- > The CLI scaffolds a complete app — routing, theming, a tab layout and thirteen > components already wired up. Pick a starting point below and copy one command. Choose the setup that matches what you are building. [Expo](/docs/installation/expo) — The components, theming and a tab layout. No backend. [Manual installation](/docs/installation/manual) — Add BNA UI to an Expo app you already created. [Expo + Convex](/docs/installation/convex) — A real-time backend with a schema and a live query. No sign-in. [Expo + Convex + Auth](/docs/installation/convex-auth) — Google, Apple, password and email OTP sign-in, pre-wired. [Expo + Supabase](/docs/installation/supabase) — Postgres with row level security, realtime and storage. No sign-in. [Expo + Supabase + Auth](/docs/installation/supabase-auth) — Password, magic links, OTP, Google, Apple and GitHub, pre-wired. [Expo + Firebase](/docs/installation/firebase) — Firestore, Cloud Storage and security rules with tests. No sign-in. [Expo + Firebase + Auth](/docs/installation/firebase-auth) — Password, Google and Apple sign-in, with owner-scoped rules. ## Which one should I pick? | You want to… | Start with | | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | Build a UI and ship it, no server | [Expo](/docs/installation/expo) | | Store data and sync it live across devices, no accounts | [Convex](/docs/installation/convex), [Supabase](/docs/installation/supabase) or [Firebase](/docs/installation/firebase) | | Have users sign in on day one | [Convex + Auth](/docs/installation/convex-auth), [Supabase + Auth](/docs/installation/supabase-auth) or [Firebase + Auth](/docs/installation/firebase-auth) | ### Convex, Supabase or Firebase? All three are real-time backends and all three starters give you the same app. They differ in where your logic lives and how authorization works. | | Convex | Supabase | Firebase | | ----------------- | ----------------------------------------- | ---------------------------------------------- | --------------------------------------------- | | Data model | Documents, schema in TypeScript | Postgres, schema in SQL migrations | Documents, no schema | | Queries | TypeScript functions on the server | Direct from the client, plus edge functions | Direct from the client | | Authorization | Checked inside each function | Row level security, enforced by Postgres | Security rules, evaluated against the _query_ | | Realtime | Every query is a live subscription | Opt in per table | Every listener is live | | Search | Built-in full-text index | `ilike`, or a `tsvector` column | Whole-word tokens, or an extension | | Offline cache | In-memory | In-memory | In-memory (no IndexedDB on RN) | | Local dev | Cloud dev deployment | Cloud, or the whole stack in Docker | Emulator suite (needs a JDK) | | Reach for it when | You want end-to-end TypeScript and no SQL | You want Postgres, SQL and a relational schema | You are already in the Google ecosystem | > Firestore evaluates a read rule against the **query**, not the documents it > would return — it refuses any query it cannot prove is scoped to rows you are > allowed to read. Postgres row level security does the opposite and silently > narrows the result. Code that moves between the Supabase and Firebase starters > has to account for that. The Firebase auth starter also has no email OTP and no GitHub provider, and its Google and Apple buttons need a development build. [The details are here.](/docs/installation/firebase-auth) Nothing in the component library depends on either, so you can start on the plain Expo path and add a backend later. ## Existing project Already have an Expo app? Don't run `init` over it. The manual guide adds the theme system and hooks to a project you already have, then leaves you to pull in components one at a time with `bna-ui add`. [Manual installation](/docs/installation/manual) — Add BNA UI to an Expo app you already created. [Browse components](/docs/components) — Every component, with a preview and its source. # Expo > Scaffold a new Expo app with BNA UI — Expo Router, light and dark theming, and thirteen components already wired up. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/installation/expo - Markdown: https://ui.ahmedbna.com/docs/installation/expo.md --- Use this if you are starting a new app and do not need a backend yet. ## Create the app ```bash npx bna-ui init my-app ``` The CLI asks for a project name if you leave it out, and detects your package manager from how you invoked it. Pass `.` as the name to scaffold into the current directory. **1.** Move into the project and start it ```bash cd my-app npx expo start ``` **2.** Open it Press `i` for the iOS simulator, `a` for Android, or `w` for web. The tab layout, theming and mode toggle all work out of the box. ## What you get ``` app/ Screens and routing (Expo Router — files become routes) ├── (tabs)/ Tab navigator: home, search, settings ├── sheet.tsx A modal sheet route └── _layout.tsx Root layout: ThemeProvider components/ui/ avoid-keyboard, button, card, icon, input, input-otp, link, mode-toggle, scroll-view, spinner, tabs, text, view hooks/ useColor, useColorScheme, useKeyboardHeight, useModeToggle providers/ mode-provider.tsx, theme-provider.tsx theme/ colors.ts, globals.ts ``` Those thirteen components are the same source `bna-ui add` installs — they are copied into your project, not imported from a package, so you own them and can edit them freely. ## Add more components ```bash npx bna-ui add avatar ``` Hooks, theme files and npm dependencies come along automatically. Run it with no arguments to browse everything interactively: ```bash npx bna-ui add ``` ## Flags | Flag | What it does | | --------------------------------- | ---------------------------------------- | | `--skip-install` | Scaffold without installing npm packages | | `--npm` `--yarn` `--pnpm` `--bun` | Force a package manager | ## pnpm Scaffolds come with an `.npmrc` and a `pnpm-workspace.yaml` pinning pnpm to the hoisted linker. Expo needs a flat `node_modules`, and pnpm's default isolated layout leaves `expo start` unable to resolve `metro-runtime`. Keep both files — [details](/docs/cli#pnpm). ## Next - [Browse the components](/docs/components) - [Customise the theme](/docs/theming) - Need a backend? [Add Convex](/docs/installation/convex) - Adding to an app you already have? [Manual installation](/docs/installation/manual) # Expo + Convex > Scaffold an Expo app with BNA UI and a Convex backend — a schema, a live query and no authentication. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/installation/convex - Markdown: https://ui.ahmedbna.com/docs/installation/convex.md --- Use this when you need to store data and sync it across devices, but do not want sign-in yet. You get everything from the [Expo starter](/docs/installation/expo) plus a Convex backend and a working demo query. If you want users to sign in, go to [Expo + Convex + Auth](/docs/installation/convex-auth) instead. ## Create the app ```bash npx bna-ui convex my-app --no-auth ``` > Plain `npx bna-ui convex` scaffolds the full authentication setup — Google, > Apple, password and email OTP. The `--no-auth` flag is what selects the > backend-only variant. The CLI scaffolds the project, installs dependencies, then runs `npx convex dev --once` for you. That step is interactive: a browser window opens so you can log in or sign up, and you pick or create a Convex project. It writes your deployment URL to `.env.local` as `EXPO_PUBLIC_CONVEX_URL`, which is what `app/_layout.tsx` reads. Pass `--skip-convex` to skip that and run it yourself later. ## Run it Convex-backed apps need two processes. In one terminal: ```bash npx convex dev ``` That watches `convex/` and pushes function changes as you save. In another: ```bash npx expo start ``` Press `i`, `a` or `w` to open the app. The home tab shows the demo task list — add a task and it round-trips through your deployment. ## What you get On top of the Expo starter: ``` app/_layout.tsx Root layout: ConvexProvider + ThemeProvider app/(tabs)/(home)/ The demo screen, reading from Convex convex/ ├── schema.ts The `tasks` table ├── tasks.ts list (query), add / toggle / remove (mutations) ├── tsconfig.json Convex's own TS config └── _generated/ Types, regenerated by `npx convex dev` ``` `package.json` gains one dependency: `convex`. ## The demo `convex/schema.ts` defines a single table: ```ts title="convex/schema.ts" import { v } from 'convex/values'; import { defineSchema, defineTable } from 'convex/server'; export default defineSchema({ tasks: defineTable({ text: v.string(), isCompleted: v.boolean(), }), }); ``` `convex/tasks.ts` exposes it. Queries read, mutations write — both are plain TypeScript functions that run on the server: ```ts title="convex/tasks.ts" export const list = query({ handler: async (ctx) => { return await ctx.db.query('tasks').order('desc').take(50); }, }); export const add = mutation({ args: { text: v.string() }, handler: async (ctx, args) => { return await ctx.db.insert('tasks', { text: args.text, isCompleted: false, }); }, }); ``` The screen subscribes with `useQuery`. It returns `undefined` while the first result is in flight, then stays live for the rest of the session — when anything writes to `tasks`, every subscribed client re-renders. There is no refetching and no cache to invalidate: ```tsx title="app/(tabs)/(home)/index.tsx" import { api } from '@/convex/_generated/api'; import { useMutation, useQuery } from 'convex/react'; const tasks = useQuery(api.tasks.list); const addTask = useMutation(api.tasks.add); if (tasks === undefined) return ; ``` Delete `convex/tasks.ts`, drop the `tasks` table from the schema and rewrite the home screen whenever you are ready to build your own thing. ## Environment | Variable | Set by | Used by | | ------------------------ | ----------------------------------- | ------------------------------------- | | `EXPO_PUBLIC_CONVEX_URL` | `npx convex dev`, into `.env.local` | `app/_layout.tsx` to build the client | `.env.local` is gitignored. Your teammates run `npx convex dev` once to get their own, or you point them at a shared deployment. ## Adding auth later Nothing here blocks it. `@convex-dev/auth` layers onto an existing Convex project — install it, run `npx @convex-dev/auth`, spread `authTables` into your schema, and swap `ConvexProvider` for `ConvexAuthProvider` in the root layout. The [auth guide](/docs/installation/convex-auth) describes the finished shape, and scaffolding a throwaway project with plain `npx bna-ui convex` is a quick way to see the pieces side by side. ## Next - [Database and schema](/docs/convex/database) - [Deployment](/docs/convex/deployment) · [Troubleshooting](/docs/convex/troubleshooting) - [Browse the components](/docs/components) - [Add authentication](/docs/installation/convex-auth) # Expo + Convex + Auth > Scaffold an Expo app with BNA UI, a Convex backend and authentication — Google, Apple, password and email OTP sign-in, pre-wired. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/installation/convex-auth - Markdown: https://ui.ahmedbna.com/docs/installation/convex-auth.md --- Everything from the [Convex starter](/docs/installation/convex), plus [`@convex-dev/auth`](https://labs.convex.dev/auth) with sign-in screens already built. This is what `npx bna-ui convex` gives you by default. ## Create the app ```bash npx bna-ui convex my-app ``` The CLI scaffolds the project, installs dependencies, then runs four steps for you. The first two are interactive — follow the prompts: **1.** npx convex dev --once A browser window opens so you can log in or sign up, then pick or create a Convex project. Your deployment URL is written to `.env.local` as `EXPO_PUBLIC_CONVEX_URL`. **2.** npx @convex-dev/auth Generates the signing keys your deployment needs to issue JWTs and sets them as deployment environment variables. **3.** npx convex env set EXPO\_URL my-app\:// Your app's deep-link scheme, so OAuth can redirect back into the app after sign-in. **4.** npx convex env set SITE\_URL http\://localhost:3000/ The web redirect target. See [production](#production) — this one needs changing before you ship. Pass `--skip-convex` to skip all four and run them yourself later. ## Run it Two processes, as with any Convex app. In one terminal: ```bash npx convex dev ``` In another: ```bash npx expo start ``` You land on the sign-in screen. **Log in anonymously** works immediately — everything else needs credentials, below. ## What you get On top of the Expo starter: ``` app/_layout.tsx ConvexAuthProvider (SecureStore on iOS/Android) + AuthLoading / Unauthenticated / Authenticated components/auth/ ├── auth.tsx The sign-in screen: Password / OAuth / OTP tabs ├── password.tsx Sign in, sign up, forgot and reset password ├── email-otp.tsx Passwordless email codes ├── google.tsx Google sign-in button ├── apple.tsx Apple sign-in button └── singout.tsx Sign-out button (used in the settings tab) convex/ ├── auth.ts Provider configuration and the redirect allow-list ├── auth.config.ts JWT issuer ├── schema.ts authTables + a users table ├── users.ts get, getAll, update, … ├── http.ts Mounts the auth HTTP routes ├── resendOTP.ts Email OTP delivery via Resend ├── passwordReset.ts Password-reset codes via Resend └── resendPasswordOTP.ts ``` The root layout swaps on auth state: a spinner while the session resolves, the sign-in screen when signed out, your tabs when signed in. Tokens are stored in the platform keychain via `expo-secure-store`. ## Configure the providers Every provider except anonymous needs credentials set on your Convex deployment. Set them with `npx convex env set` — they live on the deployment, not in `.env.local`, because they are read by server-side functions. ### Email — required for OTP and password reset Both the email OTP tab and the forgot-password flow send mail through [Resend](https://resend.com). Without this key they fail silently: ```bash npx convex env set AUTH_RESEND_KEY re_your_key_here ``` > The password tab's sign-in and sign-up work without it, but "forgot password" > and the whole OTP tab do not. See the [Resend guide](/docs/convex/resend) for > domain verification. ### Google ```bash npx convex env set AUTH_GOOGLE_ID your_google_client_id npx convex env set AUTH_GOOGLE_SECRET your_google_client_secret ``` Full walkthrough: [Google OAuth setup](/docs/convex/google). ### Apple ```bash npx convex env set AUTH_APPLE_ID your_apple_service_id npx convex env set AUTH_APPLE_SECRET your_generated_jwt ``` Full walkthrough: [Apple Sign-In setup](/docs/convex/apple). ### GitHub `convex/auth.ts` also configures GitHub, but the sign-in screen ships no button for it. Add `AUTH_GITHUB_ID` / `AUTH_GITHUB_SECRET` and a button modelled on `components/auth/google.tsx` if you want it. ## Environment reference | Variable | Where it lives | Set by | Used for | | ------------------------ | ----------------- | --------------------- | ------------------------------------------------- | | `EXPO_PUBLIC_CONVEX_URL` | `.env.local` | `npx convex dev` | Building the client in `app/_layout.tsx` | | `EXPO_URL` | Convex deployment | the CLI | Allow-listing your app scheme for OAuth redirects | | `SITE_URL` | Convex deployment | the CLI | Allow-listing the web redirect target | | `AUTH_RESEND_KEY` | Convex deployment | **you** | Email OTP and password reset | | `AUTH_GOOGLE_ID/SECRET` | Convex deployment | **you** | Google sign-in | | `AUTH_APPLE_ID/SECRET` | Convex deployment | **you** | Apple sign-in | | `CONVEX_SITE_URL` | Convex deployment | Convex, automatically | The JWT issuer in `auth.config.ts` | Check what is set at any time: ```bash npx convex env list ``` ## Password rules `convex/auth.ts` enforces a minimum of 8 characters with at least one digit, one lowercase and one uppercase letter. Edit `validatePasswordRequirements` to change that — it throws, and the message surfaces in the sign-up form. ## Production Two things bite here, both in `convex/auth.ts`'s `redirect` callback, which rejects any target that is not an `exp://` dev URL, `EXPO_URL`, or `SITE_URL`: **1.** Point SITE\_URL at your real site The CLI sets it to `http://localhost:3000/`, which is fine for development and wrong everywhere else. ```bash npx convex env set SITE_URL https://your-site.com --prod ``` **2.** Make sure EXPO\_URL matches your production scheme It is set from your project name. If you change `scheme` in `app.json`, change this to match, or OAuth redirects will be rejected. ```bash npx convex env set EXPO_URL your-scheme:// --prod ``` Then set your provider credentials against the production deployment too, and deploy: ```bash npx convex env set AUTH_RESEND_KEY re_your_prod_key --prod npx convex deploy ``` ## Before you ship **1.** Confirm SITE\_URL and EXPO\_URL are set against --prod Not just dev. Both gate the `redirect` callback above, and the CLI's defaults (`http://localhost:3000/`, your dev scheme) are wrong in production. **2.** Set every provider's credentials against --prod too `AUTH_RESEND_KEY`, `AUTH_GOOGLE_ID`/`SECRET`, `AUTH_APPLE_ID`/`SECRET` — each one, again, with `--prod`. **3.** Re-read every query and mutation as an attacker There is no RLS backstop here. `convex/users.ts` is your entire access-control surface — confirm every function that returns or changes user data checks `getAuthUserId(ctx)` against the right owner. **4.** Confirm the production EAS profile points at your prod deployment `EXPO_PUBLIC_CONVEX_URL` in the `production` build profile has to be the prod deployment's URL, not the dev one you have been testing against. Full checklist, including EAS and CI: [deployment](/docs/convex/deployment). ## Next - [Google OAuth setup](/docs/convex/google) - [Apple Sign-In setup](/docs/convex/apple) - [Resend email setup](/docs/convex/resend) - [Deployment](/docs/convex/deployment) · [Troubleshooting](/docs/convex/troubleshooting) - [Convex Auth documentation](https://labs.convex.dev/auth) # Expo + Supabase > Scaffold an Expo app with BNA UI and a Supabase backend — Postgres with row level security, realtime subscriptions, storage and an edge function, with no authentication. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/installation/supabase - Markdown: https://ui.ahmedbna.com/docs/installation/supabase.md --- Use this when you need a database, file storage and live updates, but not sign-in. Every table still has row level security enabled — "no auth" is not "no RLS", and the docs below explain why that distinction matters more here than in the auth starter. ```bash npx bna-ui supabase my-app --no-auth ``` > Plain `npx bna-ui supabase` scaffolds the full authentication setup — > password, magic links, email OTP, Google, Apple and GitHub. The `--no-auth` > flag is what selects the backend-only variant. ## Create a project **1.** Create a Supabase project At [supabase.com/dashboard](https://supabase.com/dashboard). Note the project reference — the subdomain in `https://.supabase.co`. **2.** Run the CLI ```bash npx bna-ui supabase my-app --no-auth ``` It asks for your project URL and publishable key, both from **Project Settings → API**, then writes them to `.env.local`. Press enter at either prompt to skip and fill the file in yourself later. **3.** It links, migrates and generates types If the [Supabase CLI](https://supabase.com/docs/guides/local-development) is on your `PATH`, the scaffold runs these three for you. If it is not, they are printed as next steps instead — nothing fails. ```bash npx supabase link --project-ref your-project-ref npx supabase db push npm run db:types ``` **4.** Deploy the edge function Optional; the settings tab calls it. ```bash npm run functions:deploy ``` Pass `--skip-supabase` to skip the prompts and all of the above. ## Run it ```bash npx expo start ``` Three tabs, each demonstrating one thing: a live task list, a database query, and storage plus an edge function. ## What you get On top of the Expo starter: ``` lib/ ├── supabase.ts the client — no session, so nothing is persisted ├── realtime.ts applyChange: the postgres_changes reducer └── database.types.ts generated; regenerate with `npm run db:types` hooks/ ├── useTasks.ts select + subscription + optimistic CRUD └── useUpload.ts file URI → ArrayBuffer → storage → public URL app/(tabs)/ ├── (home)/index.tsx live task list ├── search/index.tsx `ilike` query against Postgres └── settings/index.tsx storage upload + edge function + connection status supabase/ ├── config.toml local stack configuration ├── seed.sql `supabase db reset` fixtures ├── migrations/ │ ├── 0001_tasks.sql table, RLS, realtime publication, replica identity │ └── 0002_storage.sql public bucket + policies └── functions/hello-world/index.ts __tests__/realtime.test.ts jest-expo + the realtime reducer .github/workflows/ci.yml typecheck, test, type drift, deploy, EAS build ``` ## The client There is no session to persist and nothing to refresh, so all three are off: ```ts title="lib/supabase.ts" export const supabase = createClient(supabaseUrl, supabaseKey, { auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false, }, }); ``` `detectSessionInUrl` must be `false` on native regardless of auth — there is no URL to parse, and leaving it on makes the client wait for a callback that never arrives. ## Row level security This is the part to read twice. The publishable key is compiled into your app bundle, so anyone with the app has it. Your RLS policies are the only thing standing between that key and your data. The shipped migration enables RLS and then grants the `anon` role everything: ```sql title="supabase/migrations/0001_tasks.sql" alter table public.tasks enable row level security; create policy "Anyone can read tasks" on public.tasks for select to anon, authenticated using (true); ``` That is correct for a public demo and wrong for real data. Before you ship anything that matters, either tighten these policies or move to the auth starter, where every policy is scoped by `auth.uid()`. > A table you create later without `enable row level security` is readable and > writable by anyone holding the publishable key, no matter what policies you > add. Enable it first, then write policies. Full detail: [database, RLS and migrations](/docs/supabase/database). ## Realtime Two lines in the migration do the work that is easy to miss: ```sql title="supabase/migrations/0001_tasks.sql" alter publication supabase_realtime add table public.tasks; alter table public.tasks replica identity full; ``` Without the first, a subscription connects successfully and then never fires — the single most common "realtime is broken" report. Without the second, `UPDATE` and `DELETE` events carry only the primary key. The client side is `hooks/useTasks.ts`, and the reducer it uses is a pure function in `lib/realtime.ts` so it can be unit-tested without a database. More: [realtime](/docs/supabase/realtime). ## Environment | Variable | Where it lives | Set by | Used for | | -------------------------------------- | -------------- | ----------------- | ----------------------------------- | | `EXPO_PUBLIC_SUPABASE_URL` | `.env.local` | `bna-ui supabase` | Building the client | | `EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY` | `.env.local` | `bna-ui supabase` | Building the client | | `SUPABASE_SERVICE_ROLE_KEY` | Edge functions | Supabase | Server-side queries that bypass RLS | > `EXPO_PUBLIC_` variables are inlined into the JavaScript bundle at build time. > A `sb_secret_…` key there bypasses RLS entirely and is readable by anyone who > downloads the app. Secret keys belong in edge function environment variables > only — the CLI refuses one at the prompt for this reason. Supabase is retiring the legacy `anon` and `service_role` keys at the end of 2026. This starter uses the replacements, `sb_publishable_…` and `sb_secret_…`, throughout. ## Local development Everything above works against a hosted project. To run the whole stack on your machine instead — Postgres, storage, realtime, Studio and a mail catcher — install [Docker](https://docs.docker.com/desktop/) and: ```bash npx supabase start # Studio at http://localhost:54323 npx supabase db reset # applies migrations, then seed.sql npm run db:types:local ``` Point `.env.local` at the URL and key `supabase start` prints. ## Next - [Database, RLS and migrations](/docs/supabase/database) - [Storage](/docs/supabase/storage) · [Realtime](/docs/supabase/realtime) · [Edge functions](/docs/supabase/edge-functions) - [Deployment](/docs/supabase/deployment) · [Troubleshooting](/docs/supabase/troubleshooting) - [Add authentication](/docs/installation/supabase-auth) # Expo + Supabase + Auth > Scaffold an Expo app with BNA UI, a Supabase backend and authentication — password, magic links, email OTP, Google, Apple and GitHub, with protected routes, onboarding and user profiles pre-wired. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/installation/supabase-auth - Markdown: https://ui.ahmedbna.com/docs/installation/supabase-auth.md --- Everything from the [Supabase starter](/docs/installation/supabase), plus authentication with the screens already built: six routes under `(auth)`, a two-step onboarding flow, user profiles with avatar uploads, and route guards backed by row level security. This is what `npx bna-ui supabase` gives you by default. ## Create the app ```bash npx bna-ui supabase my-app ``` **1.** Create a Supabase project At [supabase.com/dashboard](https://supabase.com/dashboard). Note the project reference — the subdomain in `https://.supabase.co`. **2.** Answer the two prompts Your project URL and publishable key, both from **Project Settings → API**. They are written to `.env.local`. Press enter to skip and fill the file in yourself later. **3.** It links, migrates and generates types If the [Supabase CLI](https://supabase.com/docs/guides/local-development) is on your `PATH`, the scaffold runs these for you; if not, they are printed as next steps. Nothing fails either way. ```bash npx supabase link --project-ref your-project-ref npx supabase db push npm run db:types ``` **4.** Allow-list your redirect URLs In **Authentication → URL Configuration → Redirect URLs**, add all three. Expo Go uses the first, a build uses the second, and the password-recovery email uses the third: ``` exp://localhost:8081 my-app:// my-app://reset-password ``` **5.** Deploy the edge functions ```bash npm run functions:deploy ``` `delete-account` is what the delete button in Settings calls. It has to be a function because removing a user needs a secret key. > OAuth and magic links both come back through a deep link, and Supabase rejects > any redirect target that is not on that list. Nothing in the CLI can set it — > it lives in the dashboard. A sign-in that opens the browser and then does > nothing is almost always this. Pass `--skip-supabase` to skip the prompts and the CLI steps. ## Run it ```bash npx expo start ``` You land on the sign-in screen. Email and password work immediately; the rest need configuration, below. ## What you get On top of the Supabase starter: ``` lib/ ├── supabase.ts encrypted storage, PKCE, AppState refresh └── large-secure-store.ts AES-256 wrapper — SecureStore caps values at 2048 bytes providers/ └── auth-provider.tsx session, user, profile, and the deep-link handler hooks/ ├── useProfile.ts profile updates └── useAvatarUpload.ts image → avatars bucket → profiles.avatar_url app/ ├── _layout.tsx AuthProvider + the Stack.Protected route guards ├── (auth)/ │ ├── sign-in.tsx sign-up.tsx │ ├── magic-link.tsx verify-otp.tsx │ └── forgot-password.tsx reset-password.tsx └── (onboarding)/ ├── index.tsx three-step intro carousel └── profile.tsx display name + avatar, sets profiles.onboarded components/auth/ ├── auth-screen.tsx shared frame — title, body, keyboard avoidance ├── oauth-buttons.tsx Google / Apple / GitHub, browser PKCE └── sign-out-button.tsx supabase/migrations/ ├── 0001_profiles.sql profiles, RLS, handle_new_user trigger ├── 0002_tasks.sql per-user tasks, RLS, realtime └── 0003_storage.sql avatars + files buckets, owner-scoped policies supabase/functions/ ├── hello-world/ runs as the caller └── delete-account/ runs as admin, identifies the caller from their JWT ``` ## Sign-in methods | Method | Ships with a screen | Needs | | ---------------- | ------------------- | -------------------------------------------------- | | Email + password | Yes | SMTP, for the confirmation email | | Magic link | Yes | SMTP | | Email OTP | Yes | SMTP, and `{{ .Token }}` in the email template | | Google | Yes | Client ID and secret in Authentication → Providers | | Apple | Yes | A Services ID and a generated client secret | | GitHub | Yes | An OAuth app | Provider walkthroughs: [Google](/docs/supabase/google), [Apple](/docs/supabase/apple), [email and SMTP](/docs/supabase/email). > Supabase's built-in email service is rate-limited to a handful of messages an > hour and is explicitly not for production. Without your own provider, sign-ups > silently stop arriving once you have real users. ## How the guards work `app/_layout.tsx` mounts exactly one route group at a time: ```tsx title="app/_layout.tsx" ``` `Stack.Protected` unmounts the screens whose guard is false and redirects away from them, so with no session there is no navigation path into `(tabs)` — not by deep link either. That is the convenience layer. The boundary that actually holds is row level security: every policy filters on `auth.uid()`, so a modified client gets nothing it is not entitled to regardless of what the app renders. `needsOnboarding` is `profile?.onboarded === false` rather than `!profile?.onboarded`, because the profile row is created by a database trigger and is briefly `null` right after sign-up. Treating `null` as "not onboarded" would flash the onboarding screen at returning users. ## Session storage `expo-secure-store` refuses values larger than 2048 bytes, and a Supabase session — access token, refresh token and the whole user object — is comfortably past that. This is a nasty failure mode: a test account with no metadata can squeak under the limit, so it works in development and breaks the first time a real user signs in. `lib/large-secure-store.ts` keeps a 256-bit AES key in SecureStore and the ciphertext in AsyncStorage, which has no size limit: ```ts title="lib/supabase.ts" export const supabase = createClient(url, publishableKey, { auth: { storage: Platform.OS === 'web' ? undefined : new LargeSecureStore(), persistSession: true, autoRefreshToken: true, detectSessionInUrl: Platform.OS === 'web', flowType: 'pkce', }, }); ``` And because a refresh timer in a backgrounded app is unreliable — iOS suspends it outright — the client is told when the app comes back: ```ts title="lib/supabase.ts" AppState.addEventListener('change', (state) => { if (state === 'active') supabase.auth.startAutoRefresh(); else supabase.auth.stopAutoRefresh(); }); ``` Without that, a user who leaves the app for an hour returns to expired requests. ## OAuth One code path for all three providers, in `components/auth/oauth-buttons.tsx`: ```ts title="components/auth/oauth-buttons.tsx" const redirectTo = makeRedirectUri(); const { data } = await supabase.auth.signInWithOAuth({ provider, options: { redirectTo, skipBrowserRedirect: true }, }); const result = await openAuthSessionAsync(data.url, redirectTo); if (result.type === 'success') { const code = new URL(result.url).searchParams.get('code'); await supabase.auth.exchangeCodeForSession(code); } ``` This is browser-based PKCE. It works in Expo Go and on web, and needs no native modules or per-platform client IDs. The alternative — native sign-in SDKs feeding `signInWithIdToken` — gives a nicer sheet on iOS but requires a development build; [the auth guide](/docs/supabase/auth) covers swapping to it. `skipBrowserRedirect` stops supabase-js navigating the page itself, because we want the URL to hand to an auth session that returns control to the app. ## Deep links Magic links, email confirmations and password recovery all come back into the app as a URL. `providers/auth-provider.tsx` handles them centrally rather than in a `callback` route, because the link can arrive while any screen is mounted — and on a cold start, before the router has settled anywhere at all. ```ts title="providers/auth-provider.tsx" const handleUrl = async (url: string) => { const { queryParams } = Linking.parse(url); const code = queryParams?.code; if (typeof code === 'string') { await supabase.auth.exchangeCodeForSession(code); return; } // Older projects and some templates return tokens in the #fragment instead. // … }; Linking.getInitialURL().then((url) => url && handleUrl(url)); const subscription = Linking.addEventListener('url', ({ url }) => handleUrl(url) ); ``` ## Profiles `auth.users` belongs to Supabase and you should not write to it. Everything your app knows about a user lives in `public.profiles`, keyed by the same id, created by a trigger so it exists from the moment the user does: ```sql title="supabase/migrations/0001_profiles.sql" create function public.handle_new_user() returns trigger language plpgsql security definer set search_path = '' as $$ begin insert into public.profiles (id, email, display_name, avatar_url) values ( new.id, new.email, coalesce( new.raw_user_meta_data ->> 'display_name', new.raw_user_meta_data ->> 'full_name', new.raw_user_meta_data ->> 'name' ), coalesce( new.raw_user_meta_data ->> 'avatar_url', new.raw_user_meta_data ->> 'picture' ) ) on conflict (id) do nothing; return new; end; $$; ``` The `coalesce` calls pick up the name and picture OAuth providers supply, which is why a Google sign-up arrives with an avatar already set. > A `security definer` function runs as its owner. Without `set search_path = > ''` a user-created schema earlier in the path could shadow `public.profiles` > and capture the insert. Every definer function in the starter pins it. ## Storage Avatars go to `/avatar.` in a public bucket. The path is not cosmetic — the policies compare its first segment to the caller's id: ```sql title="supabase/migrations/0003_storage.sql" create policy "Users can upload their own avatar" on storage.objects for insert to authenticated with check ( bucket_id = 'avatars' and (storage.foldername(name))[1] = auth.uid()::text ); ``` The bucket is public so avatars render without a token; writes are still scoped to their owner. There is also a private `files` bucket, used by nothing, as the shape to copy. See [storage](/docs/supabase/storage). ## Environment reference | Variable | Where it lives | Set by | Used for | | -------------------------------------- | -------------- | ----------------- | --------------------------------------- | | `EXPO_PUBLIC_SUPABASE_URL` | `.env.local` | `bna-ui supabase` | Building the client | | `EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY` | `.env.local` | `bna-ui supabase` | Building the client | | `SUPABASE_SERVICE_ROLE_KEY` | Edge functions | Supabase | `delete-account`, which bypasses RLS | | `SUPABASE_ANON_KEY` | Edge functions | Supabase | `hello-world`, which runs as the caller | Provider credentials live in the dashboard under **Authentication → Providers**, not in any file. ## Local development To run the whole stack locally, including a mail catcher that collects every magic link and OTP, install [Docker](https://docs.docker.com/desktop/) and: ```bash npx supabase start # Studio at http://localhost:54323 npx supabase db reset # migrations + seed.sql npm run db:types:local ``` Sign-up emails land in [Inbucket](http://localhost:54324) instead of a real inbox, so the entire auth flow is testable without configuring SMTP. `supabase/config.toml` sets `enable_confirmations = false` locally so sign-up returns a session immediately. ## Before you ship **1.** Turn email confirmations on and configure real SMTP Local config has confirmations off for convenience. Production should not. **2.** Add your production scheme to the redirect allow-list If you change `scheme` in `app.json`, change this to match, or OAuth redirects are rejected. **3.** Re-read every policy as if you held the publishable key Because someone will. `supabase/migrations/` is your entire access-control surface. **4.** Confirm no secret key reached the bundle ```bash grep -r "sb_secret_" . --exclude-dir=node_modules ``` Full checklist: [deployment](/docs/supabase/deployment). ## Next - [Auth architecture](/docs/supabase/auth) · [Database and RLS](/docs/supabase/database) - [Google](/docs/supabase/google) · [Apple](/docs/supabase/apple) · [Email and SMTP](/docs/supabase/email) - [Storage](/docs/supabase/storage) · [Realtime](/docs/supabase/realtime) · [Edge functions](/docs/supabase/edge-functions) - [Deployment](/docs/supabase/deployment) · [Troubleshooting](/docs/supabase/troubleshooting) # Expo + Firebase > Scaffold an Expo app with BNA UI and a Firebase backend — Cloud Firestore with live listeners, Cloud Storage with upload progress, and security rules with tests that run them. No sign-in. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/installation/firebase - Markdown: https://ui.ahmedbna.com/docs/installation/firebase.md --- ## Create a project ```bash npx bna-ui firebase my-app --no-auth ``` Without `--no-auth` you get [the auth starter](/docs/installation/firebase-auth) instead — email and password, Google, Apple, onboarding and a profile. The CLI asks for your Firebase web config, writes `.env.local`, sets the default project in `.firebaserc`, and — if `firebase-tools` is installed — deploys the security rules and indexes for you. Skip any of it and it prints the commands. > You need a project with a **Web** app added to it, plus Firestore and Storage > enabled from the console's Build menu. The CLI cannot create those for you. > [console.firebase.google.com](https://console.firebase.google.com) ## Run it ```bash cd my-app npm start ``` Expo Go works. The Firebase JS SDK is pure JavaScript with no native module, so there is no config plugin, no `google-services.json`, and no development build needed — the whole config comes from `EXPO_PUBLIC_*` environment variables. That changes if you move to `@react-native-firebase`. > Until `firebase deploy --only firestore,storage` runs, Firestore serves > whatever rules the console last had — usually locked down — and the search > query has no index. Both show up as an error toast, not an empty screen. ## What you get Everything in the [Expo starter](/docs/installation/expo), plus: ``` lib/ ├── firebase.ts app + Firestore + Storage. Imports no auth code at all ├── documents.ts pure: snapshot → plain object, byNewest, tokenize └── errors.ts pure: Firebase error code → prose hooks/ ├── useTasks.ts one onSnapshot subscription + mutations └── useUpload.ts file URI → Blob → Storage → download URL, with progress app/(tabs)/ ├── (home)/index.tsx live task list ├── search/index.tsx array-contains query └── settings/index.tsx storage upload + getCountFromServer firestore.rules open on /tasks only firestore.indexes.json the one composite index the search query needs storage.rules public read, validated create, no delete rules-tests/ the rules, executed against the emulator ``` ## The client ```ts title="lib/firebase.ts" const existing = getApps()[0]; const app = existing ?? initializeApp(firebaseConfig); export const db = existing ? getFirestore(app) : initializeFirestore(app, { experimentalAutoDetectLongPolling: true }); export const storage = getStorage(app); ``` The `existing` guard is not defensive programming. Fast Refresh re-executes this module whenever you edit it, and `initializeApp` throws `app/duplicate-app` on a second call while `initializeFirestore` throws _"settings can no longer be changed"_. One condition covers both. There is **no `metro.config.js`** and this project does not need one. The `sourceExts.push('cjs')` and `unstable_enablePackageExports = true` advice in older Firebase + Expo threads describes defaults that Expo SDK 57 and Metro 0.84 already set. ## Realtime One `onSnapshot` subscription is the whole thing: ```ts title="hooks/useTasks.ts" return onSnapshot( query(collection(db, 'tasks'), orderBy('createdAt', 'desc'), limit(50)), { includeMetadataChanges: true }, (snapshot) => { setTasks(tasksFromSnapshot(snapshot)); setConnected(!snapshot.metadata.fromCache); }, (caught) => setError(messageFor(caught)) ); ``` Three habits worth unlearning if you are arriving from a REST or Supabase codebase: - **No initial fetch.** `onSnapshot` delivers the current result set itself, from cache first and then the server. - **No optimistic apply, and no rollback.** `addDoc` and `updateDoc` mutate the local cache synchronously, and the SDK reverts them itself if the server says no. What you _do_ still have to handle is telling the user — the awaited promise is the only place a rejection surfaces. - **No refetch on reconnect.** The stream resumes from a token. `includeMetadataChanges: true` is load-bearing for the connection indicator: without it the listener never re-fires on a metadata-only change, so `fromCache` stays true forever after the first response. ## Security rules **Every rule in `firestore.rules` is open.** Your Firebase config ships inside the app bundle, so anyone with the app can read, create and delete every task. That is deliberate for a demo and wrong for real data. They are _not_ the console's 30-day "test mode" rules, on purpose — those expire into `permission-denied` on day 31, and a demo that breaks on a timer teaches the wrong lesson. The one thing pinned down even here: ``` allow create: if isValidTask(request.resource.data) && request.resource.data.createdAt == request.time; ``` A client cannot forge a creation time. See [Security rules](/docs/firebase/rules) for the full picture, and run them: ```bash npm run rules:test ``` Needs a JDK 21 or newer — the emulators are Java processes. ## Search Firestore has no `LIKE`, no substring matching and no full-text index. The starter writes a `searchTokens` array at insert time and queries it with `array-contains`: ```ts where('searchTokens', 'array-contains', term); ``` This matches **whole words only** — "migra" finds nothing where a Postgres `ilike '%migra%'` would find "migration". When that stops being enough, put an Algolia, Typesense or Elastic extension in front of the collection. Do not fetch everything and filter in JS; you pay per document read. ## Environment | Variable | Set by | Used for | | ------------------------------------------ | ----------------- | -------------------------------- | | `EXPO_PUBLIC_FIREBASE_API_KEY` | `bna-ui firebase` | Building the client | | `EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN` | `bna-ui firebase` | Building the client | | `EXPO_PUBLIC_FIREBASE_PROJECT_ID` | `bna-ui firebase` | Building the client | | `EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET` | `bna-ui firebase` | Cloud Storage | | `EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID` | `bna-ui firebase` | Building the client | | `EXPO_PUBLIC_FIREBASE_APP_ID` | `bna-ui firebase` | Building the client | | `EXPO_PUBLIC_FIREBASE_USE_EMULATOR` | You | Point the app at local emulators | > Unlike a Supabase key, these values identify your project rather than granting > access to it. What guards your data is `firestore.rules` and `storage.rules`, > which is why this starter ships both with tests. What must _never_ go in > `.env.local` is a service account JSON or an Admin SDK private key. Projects created before October 2024 use `your-project.appspot.com` for the storage bucket rather than `.firebasestorage.app`. Copy whatever the console shows; the CLI offers the newer form as a default but lets you override it. ## Local development ```bash npm run emulators # UI at http://localhost:4000 npm run emulators:seed # three demo tasks ``` Then set `EXPO_PUBLIC_FIREBASE_USE_EMULATOR=1`. The app reads the dev server's LAN address from Expo, so this works from a real device and not just the simulator. ## Next - [Firestore](/docs/firebase/firestore) · [Realtime](/docs/firebase/realtime) · [Storage](/docs/firebase/storage) - [Security rules](/docs/firebase/rules) — read this one before you ship - [Adding auth later](/docs/installation/firebase-auth) - [Browse components](/docs/components) # Expo + Firebase + Auth > Scaffold an Expo app with BNA UI, Firebase Authentication, Cloud Firestore scoped per user, Cloud Storage avatars, and security rules with tests that run them. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/installation/firebase-auth - Markdown: https://ui.ahmedbna.com/docs/installation/firebase-auth.md --- ## Create the app ```bash npx bna-ui firebase my-app ``` This is the default. Add `--no-auth` for [the backend-only starter](/docs/installation/firebase). The CLI asks for your Firebase web config, writes `.env.local`, sets the default project in `.firebaserc`, and deploys the security rules and indexes if `firebase-tools` is installed. > Create a project with a **Web** app added to it, enable Firestore, Storage and > Authentication from the Build menu, then turn on **Email/Password** under > Authentication → Sign-in method. That is the only provider the app needs to > run. ## Run it ```bash cd my-app npm start ``` Email and password sign-in, Firestore and Storage all work in Expo Go. Google and Apple do not — see below. > Every query in this starter is scoped with `where('ownerId', '==', uid)`, > which needs a composite index. Until `firebase deploy --only > firestore,storage` runs, the task list shows an error rather than data. ## What you get ``` providers/auth-provider.tsx onAuthStateChanged + users/{uid} listener + deep links app/_layout.tsx three Stack.Protected groups app/(auth)/ five screens app/(onboarding)/ intro carousel + profile setup lib/ ├── firebase.ts app + auth (persisted) + Firestore + Storage ├── large-secure-store.ts AES-256, because Firebase's user record exceeds 2 KB ├── documents.ts pure: snapshot → plain object, byNewest, tokenize ├── auth-link.ts pure: parse an incoming Firebase action URL └── errors.ts pure: Firebase error code → prose hooks/ useTasks, useProfile, useAvatarUpload, useDeleteAccount firestore.rules owner-only storage.rules avatars//… and files//… rules-tests/ the rules, executed against the emulator ``` ## Sign-in methods | Method | Expo Go | Ships with a screen | Setup | | ------------------ | ------- | ---------------------- | ---------------------------------- | | Email + password | Yes | Yes | Enable the provider | | Password reset | Yes | Yes | None — uses Firebase's hosted page | | Email verification | Yes | Card in Settings | None | | Google | **No** | Yes | Three OAuth client IDs | | Apple | **No** | Yes | Enable the provider; iOS only | | Email link | **No** | Yes, hidden by default | A Hosting link domain | Two things the [Supabase auth starter](/docs/installation/supabase-auth) has that this one cannot: - **No email OTP.** Firebase Authentication has no six-digit email code. Its only OTP is SMS, which needs a browser-only reCAPTCHA verifier and costs money per message. There is no `verify-otp.tsx` here as a result. - **No GitHub.** `GithubAuthProvider.credential` needs an access token obtained with a client _secret_, which cannot ship in an app bundle. Supabase performs that exchange on its own servers; Firebase expects you to. ### Why Google and Apple need a development build `signInWithPopup` and `signInWithRedirect` throw `auth/operation-not-supported-in-this-environment` on React Native, so the only route is a native ID token fed to `signInWithCredential`. And `expo-auth-session`'s hosted proxy was removed in SDK 48, so under Expo Go the redirect is `exp://…`, which no Google OAuth client type accepts. ```bash npx expo run:ios # or run:android, or an EAS development build ``` With no client IDs set, `components/auth/oauth-buttons.tsx` renders **nothing at all** — including the "or" separator. A missing client ID is never a runtime error and never a button that fails when pressed. See [Google](/docs/firebase/google) and [Apple](/docs/firebase/apple). ## How the guards work ```tsx title="app/_layout.tsx" ``` `Stack.Protected` unmounts the screens whose guard is false, so with no signed-in user there is no navigation path into `(tabs)` at all — deep link or otherwise. But the guards are a **convenience, not the boundary**. `firestore.rules` says the same thing server-side, where a modified client cannot argue: ``` match /tasks/{taskId} { allow read: if isOwner(resource.data.ownerId); } ``` ### The thing that surprises people **A read rule is evaluated against the query, not against the documents it would return.** Firestore refuses any query it cannot prove in advance is limited to documents the rule allows. So this fails with `permission-denied` even for a signed-in user: ```ts query(collection(db, 'tasks'), orderBy('createdAt', 'desc')); ``` It does not quietly return only your own rows the way a Postgres RLS policy would. That is why `hooks/useTasks.ts` and the search screen both carry `where('ownerId', '==', uid)`, and why removing it breaks the screen outright rather than leaking data. This is the single most valuable thing to know when moving between the Supabase and Firebase starters. The rules tests assert it directly. ## Session storage Firebase writes one JSON blob per user under `firebase:authUser::[DEFAULT]` — uid, email, photoURL, the whole `providerData` array, and a `stsTokenManager` holding both tokens. A bare email/password account is around 1.5 KB; one Google identity with a long `photoURL` pushes it past `expo-secure-store`'s **2048-byte** ceiling. So `LargeSecureStore` puts a 256-bit AES key in the Keychain and the ciphertext in AsyncStorage: ```ts title="lib/firebase.ts" initializeAuth(app, { persistence: getReactNativePersistence(new LargeSecureStore()), }); ``` > It exists in `@firebase/auth`'s React Native build and only there. Metro picks > that build on iOS and Android, but TypeScript resolves the `types` condition, > which points at the browser build — so the symbol is real at runtime and > invisible to the compiler. `lib/firebase.ts` reads it off the namespace with a > typed lookup, and throws a readable error if it is ever absent rather than > falling back to in-memory persistence, which would sign every user out on > relaunch. ## Reading the signed-in user ```tsx import { useAuth } from '@/providers/auth-provider'; const { user, profile, loading, signOut } = useAuth(); ``` There is no `session` field — Firebase has no session object; the `User` carries `getIdToken()` and the SDK refreshes behind it. There is also no `AppState` listener keeping tokens alive, which a Supabase project needs: Firebase refreshes lazily inside `getIdToken()`, and Firestore and Storage both pull through the same `Auth` instance. `profile` is the `users/{uid}` document, kept current over an `onSnapshot` subscription. ## Profiles There is no Cloud Function creating the profile document — those need the paid Blaze plan. The provider writes it from its **snapshot listener** rather than once after sign-up: ```ts title="providers/auth-provider.tsx" if (!snapshot.exists()) { await ensureProfile(auth.currentUser); return; } ``` That self-heals: if the first write never landed, the next launch fixes it. A post-sign-up write would not. `profile` being `null` therefore means "not created yet", never "not onboarded" — treating it as the latter would flash the onboarding flow at a returning user. ## Account deletion `hooks/useDeleteAccount.ts` reauthenticates, deletes the avatar, batch-deletes tasks 500 at a time, deletes `users/{uid}`, and calls `deleteUser` **last** — so a failure part-way leaves a usable account rather than data no one can reach. It is best-effort. Firestore has no `ON DELETE CASCADE` and this runs on the user's device, so a crash mid-way leaves orphans. The robust version is Firebase's official "Delete User Data" extension or a Cloud Function on the `user.delete` trigger. Deletion also throws `auth/requires-recent-login` on a stale token, which is what the password prompt in the confirm dialog is for. A federated user is asked to sign out and back in instead. ## Email links and in-app password reset Both are off by default and both work fine that way: the reset link opens Firebase's hosted page, the user sets a password, and returns to sign in. To bring them into the app, set `EXPO_PUBLIC_FIREBASE_LINK_URL` and claim the domain natively. > The old `page.link` bounce no longer exists, and `dynamicLinkDomain` is > deprecated in favour of `linkDomain`. The supported approach is a Firebase > Hosting domain plus Universal Links / App Links — both native entitlements, so > this needs a build and cannot work in Expo Go. ```json title="app.json" { "ios": { "associatedDomains": ["applinks:your-project-id.firebaseapp.com"] }, "android": { "intentFilters": [ { "action": "VIEW", "autoVerify": true, "data": [ { "scheme": "https", "host": "your-project-id.firebaseapp.com" } ], "category": ["BROWSABLE", "DEFAULT"] } ] } } ``` These are not in `app.json` already because a placeholder host claims nothing. `url` must also be **https** on a domain listed under Authentication → Settings → Authorized domains; a custom scheme is rejected. ## Environment reference | Variable | Required | Used for | | ------------------------------------------ | --------------------- | ---------------------------- | | `EXPO_PUBLIC_FIREBASE_API_KEY` | Yes | Building the client | | `EXPO_PUBLIC_FIREBASE_AUTH_DOMAIN` | Yes | Building the client | | `EXPO_PUBLIC_FIREBASE_PROJECT_ID` | Yes | Building the client | | `EXPO_PUBLIC_FIREBASE_STORAGE_BUCKET` | Yes | Avatars | | `EXPO_PUBLIC_FIREBASE_MESSAGING_SENDER_ID` | Yes | Building the client | | `EXPO_PUBLIC_FIREBASE_APP_ID` | Yes | Building the client | | `EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID` | For Google | ID token `aud` must match it | | `EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID` | For Google on iOS | Tied to your bundle id | | `EXPO_PUBLIC_GOOGLE_ANDROID_CLIENT_ID` | For Google on Android | Tied to package + SHA-1 | | `EXPO_PUBLIC_FIREBASE_LINK_URL` | For email links | Action link destination | | `EXPO_PUBLIC_FIREBASE_USE_EMULATOR` | No | Point at local emulators | Provider secrets — the Apple team key, an OAuth client secret, SMTP credentials — live in the Firebase console, never in a file in your repository. ## Password rules `describePasswordProblem` in `sign-up.tsx` asks for 8 characters with mixed case and a digit. Firebase's own floor is **six characters and nothing else**; everything past that is this app's opinion, enforced only in the client. To make it real, configure a password policy under Authentication → Settings → Password policy (Identity Platform) and keep the two in step. ## Local development ```bash npm run emulators # UI at http://localhost:4000 npm run emulators:seed -- --uid npm run rules:test # needs JDK 21+ ``` The seed script requires a uid because every task needs an owner — find yours in the Emulator UI's Authentication tab after signing up. ## Before you ship - Read `firestore.rules` and `storage.rules` properly, and run `npm run rules:test`. - Decide whether email verification should be mandatory. It is not by default — Firebase signs a new user in immediately, verified or not, and a card in Settings does the nagging. Gate the `(tabs)` guard on `user.emailVerified` if you want it enforced. - Set a real password policy if you rely on the client-side rules. - Add `ios.bundleIdentifier` and `android.package` before an EAS build — the Google OAuth clients are tied to them. - Remember there is no offline disk cache: Firestore's persistent cache is IndexedDB, which React Native does not have. ## Next - [Authentication](/docs/firebase/auth) · [Google](/docs/firebase/google) · [Apple](/docs/firebase/apple) · [Email](/docs/firebase/email) - [Security rules](/docs/firebase/rules) · [Firestore](/docs/firebase/firestore) - [Deployment](/docs/firebase/deployment) · [Troubleshooting](/docs/firebase/troubleshooting) # Manual > Add BNA UI to an Expo app you already created — the theme system, the hooks components depend on, and the import alias that makes it all resolve. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/installation/manual - Markdown: https://ui.ahmedbna.com/docs/installation/manual.md --- Use this when you already have an Expo project. Do not run `npx bna-ui init` over it — that scaffolds a whole app and will overwrite what you have. There are two ways in. The CLI can install everything for you, or you can copy the files by hand. ### CLI **1.** Add the @/\* alias to your tsconfig.json Every file BNA UI installs imports through `@/…`, so this has to exist first: ```json title="tsconfig.json" { "extends": "expo/tsconfig.base", "compilerOptions": { "strict": true, "paths": { "@/*": ["./*"] } }, "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"] } ``` This is also what tells the CLI where files go. If your app lives under `src/`, map the alias there — `"@/*": ["./src/*"]` — and `add` installs into `src/components/ui/…` to match. **2.** Add any component ```bash npx bna-ui add button ``` The theme files and hooks it depends on come along automatically, as do its npm dependencies. Run it with no arguments to browse everything: ```bash npx bna-ui add ``` **3.** Wrap your root layout See [wire it up](#wire-it-up) below. ### Manual **1.** Install the dependencies ```bash npx expo install expo-router ``` **2.** Add the @/\* alias to your tsconfig.json ```json title="tsconfig.json" { "extends": "expo/tsconfig.base", "compilerOptions": { "strict": true, "paths": { "@/*": ["./*"] } }, "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"] } ``` **3.** Point main at the Expo Router entry ```json title="package.json" { "main": "expo-router/entry" } ``` **4.** Create the theme Two files under `theme/`, the design tokens themselves. `colors.ts` holds every semantic token components read — change these and the whole app follows: ```ts // theme/colors.ts const lightColors = { // Base colors background: '#FFFFFF', foreground: '#000000', // Card colors card: '#F2F2F7', cardForeground: '#000000', // Popover colors popover: '#F2F2F7', popoverForeground: '#000000', // Primary colors primary: '#18181b', primaryForeground: '#FFFFFF', // Secondary colors secondary: '#F2F2F7', secondaryForeground: '#18181b', // Muted colors muted: '#78788033', mutedForeground: '#71717a', // Accent colors accent: '#F2F2F7', accentForeground: '#18181b', // Destructive colors destructive: '#ef4444', destructiveForeground: '#FFFFFF', // Border and input border: '#C6C6C8', input: '#e4e4e7', ring: '#a1a1aa', // Text colors text: '#000000', textMuted: '#71717a', // Legacy support for existing components tint: '#18181b', icon: '#71717a', tabIconDefault: '#71717a', tabIconSelected: '#18181b', // Default buttons, links, Send button, selected tabs blue: '#007AFF', // Success states, FaceTime buttons, completed tasks green: '#34C759', // Delete buttons, error states, critical alerts red: '#FF3B30', // VoiceOver highlights, warning states orange: '#FF9500', // Notes app accent, Reminders highlights yellow: '#FFCC00', // Pink accent color for various UI elements pink: '#FF2D92', // Purple accent for creative apps and features purple: '#AF52DE', // Teal accent for communication features teal: '#5AC8FA', // Indigo accent for system features indigo: '#5856D6', // Semantic states success: '#22c55e', successForeground: '#ffffff', warning: '#f59e0b', warningForeground: '#ffffff', info: '#3b82f6', infoForeground: '#ffffff', error: '#ef4444', errorForeground: '#ffffff', }; const darkColors = { // Base colors background: '#000000', foreground: '#FFFFFF', // Card colors card: '#1C1C1E', cardForeground: '#FFFFFF', // Popover colors popover: '#18181b', popoverForeground: '#FFFFFF', // Primary colors primary: '#e4e4e7', primaryForeground: '#18181b', // Secondary colors secondary: '#1C1C1E', secondaryForeground: '#FFFFFF', // Muted colors muted: '#78788033', mutedForeground: '#a1a1aa', // Accent colors accent: '#1C1C1E', accentForeground: '#FFFFFF', // Destructive colors destructive: '#dc2626', destructiveForeground: '#FFFFFF', // Border and input - using alpha values for better blending border: '#38383A', input: 'rgba(255, 255, 255, 0.15)', ring: '#71717a', // Text colors text: '#FFFFFF', textMuted: '#a1a1aa', // Legacy support for existing components tint: '#FFFFFF', icon: '#a1a1aa', tabIconDefault: '#a1a1aa', tabIconSelected: '#FFFFFF', // Default buttons, links, Send button, selected tabs blue: '#0A84FF', // Success states, FaceTime buttons, completed tasks green: '#30D158', // Delete buttons, error states, critical alerts red: '#FF453A', // VoiceOver highlights, warning states orange: '#FF9F0A', // Notes app accent, Reminders highlights yellow: '#FFD60A', // Pink accent color for various UI elements pink: '#FF375F', // Purple accent for creative apps and features purple: '#BF5AF2', // Teal accent for communication features teal: '#64D2FF', // Indigo accent for system features indigo: '#5E5CE6', // Semantic states success: '#16a34a', successForeground: '#ffffff', warning: '#d97706', warningForeground: '#ffffff', info: '#2563eb', infoForeground: '#ffffff', error: '#dc2626', errorForeground: '#ffffff', }; export const Colors = { light: lightColors, dark: darkColors, }; // Export individual color schemes for easier access export { darkColors, lightColors }; // Utility type for color keys export type ColorKeys = keyof typeof lightColors; // Helper function to get color with opacity (useful for React Native) export const withOpacity = (color: string, opacity: number) => { // Handle rgba colors if (color.startsWith('rgba')) { return color; } // Handle hex colors if (color.startsWith('#')) { const hex = color.replace('#', ''); const r = parseInt(hex.substr(0, 2), 16); const g = parseInt(hex.substr(2, 2), 16); const b = parseInt(hex.substr(4, 2), 16); return `rgba(${r}, ${g}, ${b}, ${opacity})`; } return color; }; ``` ```ts // theme/globals.ts export const HEIGHT = 48; export const FONT_SIZE = 17; export const BORDER_RADIUS = 26; export const CORNERS = 999; export const SPACING = { xs: 4, sm: 8, md: 16, lg: 24, xl: 32, }; ``` **5.** Create the providers Two files under `providers/`, the context that reads those tokens. `mode-provider.tsx` holds the app-wide light/dark/system choice — everything resolves its colors through it, and it is what makes the toggle work on web, where react-native-web's `Appearance` has no setter to write an override through: ```tsx // providers/mode-provider.tsx import { createContext, useCallback, useContext, useEffect, useMemo, useState, } from 'react'; import { Appearance, useColorScheme as useRNColorScheme } from 'react-native'; export type Mode = 'light' | 'dark' | 'system'; /** * The slice of a key/value store this needs. Returns are sync-or-async so a * store satisfies it whichever style it exposes — `expo-secure-store` (sync * `getItem`/`setItem`) and `AsyncStorage` (promise-returning) both pass * unchanged, with no adapter. * * Keeping it structural is what lets persistence cost an app one prop and this * package zero dependencies — nobody installing `useColor` pays for a storage * engine they may not want. */ export type ModeStorage = { getItem: (key: string) => string | null | Promise; setItem: (key: string, value: string) => void | Promise; }; type ModeContextValue = { /** What the app was asked for, including the `'system'` passthrough. */ mode: Mode; setMode: (mode: Mode) => void; /** * What the app should actually render as. Prefer this over re-deriving it * from `mode` — outside `'system'` there is no meaningful system value to * fall back to, and on native RN's own `useColorScheme()` reports the * override rather than the OS once `setMode` has run. */ scheme: 'light' | 'dark'; }; const ModeContext = createContext(null); const isMode = (value: unknown): value is Mode => value === 'light' || value === 'dark' || value === 'system'; /** * Mirrors the override into React Native's global `Appearance` so native chrome * follows the toggle too — the status bar, the Android navigation bar, native * sheet presentation, and anything reading the OS scheme *above* this provider * (root layouts do exactly that to colour the system UI). * * `Appearance.setColorScheme` landed in React Native 0.73 and react-native-web * has never implemented it, so feature-detect rather than assume. On web the * context alone drives the theme, which is why the toggle works there without * this call. */ function syncNativeAppearance(mode: Mode) { if (typeof Appearance.setColorScheme !== 'function') return; // RN 0.86 replaced the old `null` sentinel ("follow the system") with // `'unspecified'`. Appearance.setColorScheme(mode === 'system' ? 'unspecified' : mode); } type Props = { children: React.ReactNode; /** Supply to persist the choice across launches. Omit and it resets. */ storage?: ModeStorage; storageKey?: string; defaultMode?: Mode; }; export const ModeProvider = ({ children, storage, storageKey = 'bna-ui.mode', defaultMode = 'system', }: Props) => { const [mode, setModeState] = useState(defaultMode); const systemScheme = useRNColorScheme() === 'dark' ? 'dark' : 'light'; // Rehydrate once. A missing, malformed or unreadable value leaves the default // in place — persistence is a convenience and must never be able to break boot. // // The `Promise.resolve().then(...)` wrapper is doing real work: it normalises // sync and async stores into one path, and turns a *synchronous* throw into a // rejection `.catch` can see. `expo-secure-store` throws exactly that way on // web, where it is unsupported. useEffect(() => { if (!storage) return; let cancelled = false; Promise.resolve() .then(() => storage.getItem(storageKey)) .then((saved) => { if (cancelled || !isMode(saved)) return; setModeState(saved); syncNativeAppearance(saved); }) .catch(() => {}); return () => { cancelled = true; }; }, [storage, storageKey]); const setMode = useCallback( (next: Mode) => { setModeState(next); syncNativeAppearance(next); if (storage) { Promise.resolve() .then(() => storage.setItem(storageKey, next)) .catch(() => {}); } }, [storage, storageKey] ); // This sits at the app root, so an unstable value re-renders every themed // component in the tree on any parent render. const value = useMemo( () => ({ mode, setMode, scheme: mode === 'system' ? systemScheme : mode, }), [mode, setMode, systemScheme] ); return {children}; }; /** * `null` when no `ModeProvider` is mounted, so callers can fall back to the * system scheme instead of forcing every app that only wanted `useColor` to * mount a provider. */ export function useModeContext(): ModeContextValue | null { return useContext(ModeContext); } ``` ```tsx // providers/theme-provider.tsx import { DarkTheme, DefaultTheme, ThemeProvider as RNThemeProvider, } from 'expo-router/react-navigation'; import { useMemo } from 'react'; import { useColorScheme } from '@/hooks/useColorScheme'; import { Colors } from '@/theme/colors'; import { Mode, ModeProvider, ModeStorage } from '@/providers/mode-provider'; type Props = { children: React.ReactNode; /** Supply to persist the theme choice across launches. Omit and it resets. */ storage?: ModeStorage; storageKey?: string; defaultMode?: Mode; }; /** * Mounts `ModeProvider` — the app-wide source of truth for light/dark/system — * and maps the resolved scheme onto React Navigation's theme. * * The navigation half is a separate component because it calls * `useColorScheme()`, which has to read that context from *inside* the provider. */ export const ThemeProvider = ({ children, storage, storageKey, defaultMode, }: Props) => ( {children} ); const NavigationTheme = ({ children }: { children: React.ReactNode }) => { const colorScheme = useColorScheme(); // Rebuilding this on every render invalidates every useTheme() consumer // app-wide, since ThemeProvider is mounted at the root — memoize on the // one thing it actually depends on, and only build the active theme. const theme = useMemo(() => { if (colorScheme === 'dark') { return { ...DarkTheme, colors: { ...DarkTheme.colors, primary: Colors.dark.primary, background: Colors.dark.background, card: Colors.dark.card, text: Colors.dark.text, border: Colors.dark.border, notification: Colors.dark.red, }, }; } return { ...DefaultTheme, colors: { ...DefaultTheme.colors, primary: Colors.light.primary, background: Colors.light.background, card: Colors.light.card, text: Colors.light.text, border: Colors.light.border, notification: Colors.light.red, }, }; }, [colorScheme]); return {children}; }; ``` **6.** Create the hooks `useColorScheme` needs both files — the `.web.ts` variant defers to `light` until hydration so static web renders don't flash: ```ts // hooks/useColorScheme.ts import { useColorScheme as useRNColorScheme } from 'react-native'; import { useModeContext } from '@/providers/mode-provider'; /** * The one place the app's colour scheme is decided. * * A mounted `ModeProvider` wins, so an in-app light/dark toggle works on every * platform — including web, where react-native-web has no * `Appearance.setColorScheme` for the toggle to write through. With no provider * this is just the OS scheme, so installing `useColor` alone still behaves as * it always has. * * React Native 0.86 widened `ColorSchemeName` to `'light' | 'dark' | * 'unspecified'`. The theme is binary — `Colors` only has `light` and `dark` * keys — so collapse the third value here, once, and let every consumer keep * indexing with a two-value union. */ export function useColorScheme(): 'light' | 'dark' { const system = useRNColorScheme() === 'dark' ? 'dark' : 'light'; return useModeContext()?.scheme ?? system; } ``` ```ts // hooks/useColorScheme.web.ts import { useEffect, useState } from 'react'; import { useColorScheme as useRNColorScheme } from 'react-native'; import { useModeContext } from '@/providers/mode-provider'; /** * To support static rendering, this value needs to be re-calculated on the client side for web. * * Mirrors the native variant: a mounted `ModeProvider` wins, falling back to the * OS scheme. The provider is what makes the toggle work here at all — * react-native-web's `Appearance` is read-only, exposing `getColorScheme` and * `addChangeListener` but no setter, so nothing can push an override into the * value `useRNColorScheme()` reports. * * React Native 0.86's `ColorSchemeName` includes `'unspecified'`, which the * binary theme has no slot for, so it collapses here. */ export function useColorScheme(): 'light' | 'dark' { const [hasHydrated, setHasHydrated] = useState(false); useEffect(() => { setHasHydrated(true); }, []); const system = useRNColorScheme() === 'dark' ? 'dark' : 'light'; const scheme = useModeContext()?.scheme ?? system; if (hasHydrated) { return scheme; } return 'light'; } ``` `useColor` is what every component calls to resolve a token for the active scheme: ```ts // hooks/useColor.ts import { useColorScheme } from '@/hooks/useColorScheme'; import { Colors } from '@/theme/colors'; export function useColor( colorName: keyof typeof Colors.light & keyof typeof Colors.dark, props?: { light?: string; dark?: string } ) { const theme = useColorScheme() ?? 'light'; const colorFromProps = props?.[theme]; if (colorFromProps) { return colorFromProps; } else { return Colors[theme][colorName]; } } ``` `useModeToggle` backs the light/dark/system switch: ```tsx // hooks/useModeToggle.tsx import { Mode, useModeContext } from '@/providers/mode-provider'; interface UseModeToggleReturn { isDark: boolean; mode: Mode; setMode: (mode: Mode) => void; currentMode: 'light' | 'dark'; toggleMode: () => void; } /** * Reads and writes the app-wide theme mode held by `ModeProvider`. * * The mode deliberately lives in context rather than in this hook: it used to * be local `useState` paired with a global `Appearance.setColorScheme` call, so * remounting the toggle reset the cycle to `'system'` while the app stayed * dark, and two toggles on screen disagreed. Sharing the state also makes the * toggle work on web, where `Appearance` is read-only. */ export function useModeToggle(): UseModeToggleReturn { const context = useModeContext(); if (!context) { throw new Error( 'useModeToggle requires a . Wrap your app in the ' + ' in providers/theme-provider, which mounts one, or ' + 'mount from providers/mode-provider yourself.' ); } const { mode, setMode, scheme } = context; const toggleMode = () => { switch (mode) { case 'light': setMode('dark'); break; case 'dark': setMode('system'); break; case 'system': setMode('light'); break; } }; return { isDark: scheme === 'dark', mode, setMode, currentMode: scheme, toggleMode, }; } ``` **7.** Wrap your root layout See below. ## Wire it up Wrap your root layout in `ThemeProvider` so components can resolve colours: ```tsx title="app/_layout.tsx" import { Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; import { ThemeProvider } from '@/providers/theme-provider'; export default function RootLayout() { return ( ); } ``` ## Using it Components read colours through `useColor`, so light and dark work with no per-component wiring: ```tsx import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; export default function Screen() { const background = useColor('background'); return ( Hello ); } ``` ## Next - [Browse the components](/docs/components) - [Theming](/docs/theming) — how the tokens fit together - [CLI reference](/docs/cli) # Components > Here you can find all the components available in the library. We are working on adding more components. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components - Markdown: https://ui.ahmedbna.com/docs/components.md --- - [Accordion](/docs/components/accordion) - [Action Sheet](/docs/components/action-sheet) - [Alert Dialog](/docs/components/alert-dialog) - [Alert](/docs/components/alert) - [Audio Player](/docs/components/audio-player) - [Audio Recorder](/docs/components/audio-recorder) - [Audio Waveform](/docs/components/audio-waveform) - [Avatar](/docs/components/avatar) - [AvoidKeyboard](/docs/components/avoid-keyboard) - [Badge](/docs/components/badge) - [BottomSheet](/docs/components/bottom-sheet) - [Button](/docs/components/button) - [Camera Preview](/docs/components/camera-preview) - [Camera](/docs/components/camera) - [Card](/docs/components/card) - [Carousel](/docs/components/carousel) - [Checkbox](/docs/components/checkbox) - [Collapsible](/docs/components/collapsible) - [Color Picker](/docs/components/color-picker) - [Combobox](/docs/components/combobox) - [Date Picker](/docs/components/date-picker) - [File Picker](/docs/components/file-picker) - [Gallery](/docs/components/gallery) - [Hello Wave](/docs/components/hello-wave) - [Icon](/docs/components/icon) - [Image](/docs/components/image) - [Input OTP](/docs/components/input-otp) - [Input](/docs/components/input) - [Link](/docs/components/link) - [MediaPicker](/docs/components/media-picker) - [Mode Toggle](/docs/components/mode-toggle) - [Onboarding](/docs/components/onboarding) - [ParallaxScrollView](/docs/components/parallax-scrollview) - [Picker](/docs/components/picker) - [Popover](/docs/components/popover) - [Progress](/docs/components/progress) - [Radio](/docs/components/radio) - [ScrollView](/docs/components/scroll-view) - [SearchBar](/docs/components/searchbar) - [Separator](/docs/components/separator) - [Share](/docs/components/share) - [Sheet](/docs/components/sheet) - [Skeleton](/docs/components/skeleton) - [Spinner](/docs/components/spinner) - [Switch](/docs/components/switch) - [Table](/docs/components/table) - [Tabs](/docs/components/tabs) - [Text](/docs/components/text) - [Toast](/docs/components/toast) - [Toggle](/docs/components/toggle) - [Video](/docs/components/video) - [View](/docs/components/view) # Accordion > A vertically stacked set of interactive headings that each reveal a section of content. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/accordion - Markdown: https://ui.ahmedbna.com/docs/components/accordion.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/accordion.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/accordion.json - Install: `npx bna-ui add accordion` - npm dependencies: `expo-haptics`, `lucide-react-native`, `react-native-reanimated`, `react-native-svg`, `react-native-worklets` - Registry dependencies: `useHaptics`, `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `text`, `view`, `icon` - Preview recording: https://demo.ahmedbna.com/0001-accordion-demo.mov --- **Example:** A basic accordion with collapsible sections ```tsx // components/demo/accordion/accordion-demo.tsx import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from '@/components/ui/accordion'; import { Text } from '@/components/ui/text'; import React from 'react'; export function AccordionDemo() { return ( Is it accessible? Yes. It adheres to the WAI-ARIA design pattern. Is it styled? Yes. It comes with default styles that matches the other components' aesthetic. Is it animated? Yes. It's animated by default, but you can disable it if you prefer. ); } ``` ## Installation ### CLI ```bash npx bna-ui add accordion ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install lucide-react-native ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/accordion.tsx import { Icon } from '@/components/ui/icon'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useHaptics } from '@/hooks/useHaptics'; import { ChevronRight } from 'lucide-react-native'; import React, { createContext, useContext, useState } from 'react'; import { TouchableOpacity } from 'react-native'; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; // Context for accordion state interface AccordionContextType { type: 'single' | 'multiple'; collapsible?: boolean; value?: string | string[]; onValueChange?: (value: string | string[]) => void; haptic?: boolean; } const AccordionContext = createContext(null); // Main Accordion component interface AccordionProps { type: 'single' | 'multiple'; collapsible?: boolean; defaultValue?: string | string[]; value?: string | string[]; onValueChange?: (value: string | string[]) => void; children: React.ReactNode; haptic?: boolean; } export function Accordion({ type, collapsible = false, defaultValue, value: controlledValue, onValueChange, children, haptic = true, }: AccordionProps) { const [internalValue, setInternalValue] = useState( defaultValue || (type === 'multiple' ? [] : '') ); const value = controlledValue !== undefined ? controlledValue : internalValue; const handleValueChange = (newValue: string | string[]) => { if (controlledValue === undefined) { setInternalValue(newValue); } onValueChange?.(newValue); }; return ( {children} ); } // AccordionItem component interface AccordionItemProps { value: string; children: React.ReactNode; } export function AccordionItem({ value, children }: AccordionItemProps) { const context = useContext(AccordionContext); // Called before the guard below so the hook order stays stable. const feedback = useHaptics(context?.haptic ?? true); if (!context) { throw new Error('AccordionItem must be used within an Accordion'); } const isOpen = Array.isArray(context.value) ? context.value.includes(value) : context.value === value; const toggle = () => { if (!context.onValueChange) return; if (context.type === 'single') { // A non-collapsible single accordion keeps the open item open, so tapping // it changes nothing and must not feel like it did. const willClose = isOpen && !!context.collapsible; if (!isOpen || willClose) { feedback(willClose ? 'toggle-off' : 'toggle-on'); } const newValue = willClose ? '' : value; context.onValueChange(newValue); } else { feedback(isOpen ? 'toggle-off' : 'toggle-on'); const currentValues = Array.isArray(context.value) ? context.value : []; const newValue = isOpen ? currentValues.filter((v) => v !== value) : [...currentValues, value]; context.onValueChange(newValue); } }; return ( {children} ); } // Context for accordion item interface AccordionItemContextType { value: string; isOpen: boolean; toggle: () => void; } const AccordionItemContext = createContext( null ); // AccordionTrigger component interface AccordionTriggerProps { children: React.ReactNode; } export function AccordionTrigger({ children }: AccordionTriggerProps) { const context = useContext(AccordionItemContext); if (!context) { throw new Error('AccordionTrigger must be used within an AccordionItem'); } return ( {children} ); } // AccordionContent component interface AccordionContentProps { children: React.ReactNode; style?: object; } export function AccordionContent({ children, style }: AccordionContentProps) { const context = useContext(AccordionItemContext); if (!context) { throw new Error('AccordionContent must be used within an AccordionItem'); } if (!context.isOpen) { return null; } return ( {children} ); } ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from '@/components/ui/accordion'; ``` ```tsx Is it accessible? Yes. It adheres to the WAI-ARIA design pattern. ``` ## Examples #### Default **Example:** A basic accordion with single selection and collapsible behavior ```tsx // components/demo/accordion/accordion-demo.tsx import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from '@/components/ui/accordion'; import { Text } from '@/components/ui/text'; import React from 'react'; export function AccordionDemo() { return ( Is it accessible? Yes. It adheres to the WAI-ARIA design pattern. Is it styled? Yes. It comes with default styles that matches the other components' aesthetic. Is it animated? Yes. It's animated by default, but you can disable it if you prefer. ); } ``` #### Single Selection **Example:** An accordion that allows only one item to be open at a time ```tsx // components/demo/accordion/accordion-single.tsx import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from '@/components/ui/accordion'; import { Text } from '@/components/ui/text'; import React from 'react'; export function AccordionSingle() { return ( What is React Native? React Native is a framework for building native mobile applications using React. It allows you to create mobile apps for iOS and Android using JavaScript and React components. What is Expo? Expo is a platform for making universal native apps that run on Android, iOS, and the web. It provides a set of tools and services built around React Native. What is TypeScript? TypeScript is a programming language developed by Microsoft. It is a strict syntactical superset of JavaScript and adds optional static type checking to the language. ); } ``` #### Multiple Selection **Example:** An accordion that allows multiple items to be open simultaneously ```tsx // components/demo/accordion/accordion-multiple.tsx import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from '@/components/ui/accordion'; import { Text } from '@/components/ui/text'; import React from 'react'; export function AccordionMultiple() { return ( Frontend Technologies Modern frontend development includes React, Vue, Angular, and many other frameworks that help build interactive user interfaces. Backend Technologies Backend development involves server-side technologies like Node.js, Python, Java, and databases to handle data and business logic. Mobile Development Mobile development can be done natively with Swift/Kotlin or with cross-platform solutions like React Native, Flutter, or Xamarin. DevOps & Cloud DevOps practices and cloud platforms like AWS, Azure, and GCP help deploy, scale, and maintain applications efficiently. ); } ``` #### Controlled **Example:** An accordion with controlled state management ```tsx // components/demo/accordion/accordion-controlled.tsx import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from '@/components/ui/accordion'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function AccordionControlled() { const [value, setValue] = React.useState(''); return ( Currently open: {value || 'None'} Settings Configure your application preferences, notifications, and account settings here. Privacy Manage your privacy settings, data sharing preferences, and visibility controls. Security Set up two-factor authentication, change passwords, and review security logs. ); } ``` #### FAQ Style **Example:** An accordion formatted as a frequently asked questions section ```tsx // components/demo/accordion/accordion-faq.tsx import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from '@/components/ui/accordion'; import { Text } from '@/components/ui/text'; import React from 'react'; export function AccordionFAQ() { return ( How long does shipping take? Standard shipping typically takes 3-5 business days. Express shipping is available for 1-2 business days delivery. What is your return policy? We offer a 30-day return policy for all items in original condition. Return shipping is free for defective items. Do you offer warranty? Yes, all products come with a 1-year manufacturer warranty. Extended warranty options are available at checkout. How can I contact support? You can reach our support team via email at support@example.com or through our live chat feature available 24/7. ); } ``` #### Non-Collapsible **Example:** An accordion where at least one item must always remain open ```tsx // components/demo/accordion/accordion-non-collapsible.tsx import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from '@/components/ui/accordion'; import { Text } from '@/components/ui/text'; import React from 'react'; export function AccordionNonCollapsible() { return ( Step 1: Planning Start by defining your project requirements and creating a detailed plan. This includes wireframing and technical specifications. Step 2: Development Begin the development process by setting up your environment and implementing the core features according to your plan. Step 3: Testing Thoroughly test your application across different devices and scenarios to ensure it works as expected. Step 4: Deployment Deploy your application to production and monitor its performance. Set up analytics and error tracking. ); } ``` #### Custom Styled **Example:** An accordion with custom styling and icons ```tsx // components/demo/accordion/accordion-styled.tsx import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from '@/components/ui/accordion'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { BORDER_RADIUS } from '@/theme/globals'; import React from 'react'; export function AccordionStyled() { const card = useColor('card'); return ( 🚀 Features • Cross-platform compatibility{'\n'}• TypeScript support{'\n'}• Theme system integration{'\n'}• Customizable animations ⚡ Performance • Optimized rendering{'\n'}• Minimal re-renders{'\n'}• Smooth animations{'\n'}• Memory efficient ♿ Accessibility • Screen reader support{'\n'}• Keyboard navigation{'\n'}• Focus management{'\n'}• ARIA attributes ); } ``` ## API Reference ### Accordion Contains all the parts of a collapsible accordion. | Prop | Type | Default | Description | | --------------- | ------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `haptic` | `boolean` | `true` | Whether to trigger haptic feedback when an item expands or collapses. | | `type` | `'single' \| 'multiple'` | - | Determines whether one or multiple items can be opened at the same time. | | `value` | `string \| string[]` | - | The controlled value of the item to expand when `type` is `"single"` or the controlled values of the items to expand when `type` is `"multiple"`. | | `defaultValue` | `string \| string[]` | - | The value of the item to expand when initially rendered when `type` is `"single"` or the values of the items to expand when initially rendered when `type` is `"multiple"`. | | `onValueChange` | `(value: string \| string[]) => void` | - | Event handler called when the expanded state of an item changes. | | `collapsible` | `boolean` | `false` | When `type` is `"single"`, allows closing content when clicking trigger for an open item. | | `children` | `React.ReactNode` | - | One or more `AccordionItem` elements. | ### AccordionItem Contains all the parts of a collapsible item. | Prop | Type | Description | | ---------- | ----------------- | -------------------------------------------------- | | `value` | `string` | A unique value for the item. | | `children` | `React.ReactNode` | An `AccordionTrigger` and `AccordionContent` pair. | ### AccordionTrigger Toggles the collapsed state of its associated item. | Prop | Type | Description | | ---------- | ----------------- | -------------------------------------- | | `children` | `React.ReactNode` | The content to display in the trigger. | ### AccordionContent Contains the collapsible content for an item. | Prop | Type | Description | | ---------- | ----------------- | ---------------------------------------------------- | | `children` | `React.ReactNode` | The content to display when the item is expanded. | | `style` | `object` | Additional styles to apply to the content container. | ## Accessibility Adheres to the [Accordion WAI-ARIA design pattern](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/). # Action Sheet > A native-feeling action sheet component that provides a menu of options triggered from the bottom of the screen. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/action-sheet - Markdown: https://ui.ahmedbna.com/docs/components/action-sheet.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/action-sheet.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/action-sheet.json - Install: `npx bna-ui add action-sheet` - npm dependencies: `expo-haptics`, `react-native-reanimated`, `react-native-safe-area-context`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `useHaptics`, `globals`, `text`, `view` - Preview recording: https://demo.ahmedbna.com/0007-action-sheet-demo.MP4 --- **Example:** A basic action sheet with multiple options ```tsx // components/demo/action-sheet/action-sheet-demo.tsx import { ActionSheet } from '@/components/ui/action-sheet'; import { Button } from '@/components/ui/button'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function ActionSheetDemo() { const [visible, setVisible] = useState(false); const options = [ { title: 'Edit', onPress: () => console.log('Edit pressed'), }, { title: 'Share', onPress: () => console.log('Share pressed'), }, { title: 'Delete', onPress: () => console.log('Delete pressed'), destructive: true, }, ]; return ( setVisible(false)} title='Choose an action' message='Select one of the options below' options={options} /> ); } ``` ## Installation ### CLI ```bash npx bna-ui add action-sheet ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install react-native-reanimated ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/action-sheet.tsx import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { useHaptics } from '@/hooks/useHaptics'; import { BORDER_RADIUS, FONT_SIZE } from '@/theme/globals'; import React, { useEffect, useState } from 'react'; import { ActionSheetIOS, Dimensions, Modal, Platform, Pressable, ScrollView, StyleSheet, TouchableOpacity, ViewStyle, } from 'react-native'; import Animated, { Easing, interpolate, runOnJS, useAnimatedStyle, useSharedValue, withTiming, } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; export interface ActionSheetOption { title: string; onPress: () => void; destructive?: boolean; disabled?: boolean; icon?: React.ReactNode; } interface ActionSheetProps { visible: boolean; onClose: () => void; title?: string; message?: string; options: ActionSheetOption[]; cancelButtonTitle?: string; style?: ViewStyle; haptic?: boolean; } export function ActionSheet({ visible, onClose, title, message, options, cancelButtonTitle = 'Cancel', style, haptic = true, }: ActionSheetProps) { // Called above the platform branch below so the hook order stays stable. const feedback = useHaptics(haptic); // Use iOS native ActionSheet on iOS if (Platform.OS === 'ios') { useEffect(() => { if (visible) { const optionTitles = options.map((option) => option.title); const destructiveButtonIndex = options.findIndex( (option) => option.destructive ); const disabledButtonIndices = options .map((option, index) => (option.disabled ? index : -1)) .filter((index) => index !== -1); ActionSheetIOS.showActionSheetWithOptions( { title, message, options: [...optionTitles, cancelButtonTitle], cancelButtonIndex: optionTitles.length, destructiveButtonIndex: destructiveButtonIndex !== -1 ? destructiveButtonIndex : undefined, disabledButtonIndices: disabledButtonIndices.length > 0 ? disabledButtonIndices : undefined, }, (buttonIndex) => { if (buttonIndex < optionTitles.length) { // ActionSheetIOS emits no haptic of its own. feedback( options[buttonIndex].destructive ? 'warning' : 'selection' ); options[buttonIndex].onPress(); } onClose(); } ); } }, [ visible, title, message, options, cancelButtonTitle, onClose, feedback, ]); // Return null for iOS as we use the native ActionSheet return null; } // Custom implementation for Android and other platforms return ( ); } // Custom ActionSheet implementation for Android using react-native-reanimated function AndroidActionSheet({ visible, onClose, title, message, options, cancelButtonTitle, style, haptic = true, }: ActionSheetProps) { const [isSheetVisible, setIsSheetVisible] = useState(visible); const feedback = useHaptics(haptic); const progress = useSharedValue(0); const screenHeight = Dimensions.get('window').height; const insets = useSafeAreaInsets(); const cardColor = useColor('card'); const textColor = useColor('text'); const mutedColor = useColor('textMuted'); const borderColor = useColor('border'); const destructiveColor = useColor('red'); useEffect(() => { if (visible) { setIsSheetVisible(true); progress.value = withTiming(1, { duration: 300, easing: Easing.out(Easing.quad), }); } else { // Animate out, then set the modal to invisible after the animation is done progress.value = withTiming( 0, { duration: 250, easing: Easing.in(Easing.quad) }, (finished) => { if (finished) { runOnJS(setIsSheetVisible)(false); } } ); } }, [visible, progress]); // Animated style for the backdrop const backdropAnimatedStyle = useAnimatedStyle(() => ({ opacity: progress.value, })); // Animated style for the sheet itself (slide up/down) const sheetAnimatedStyle = useAnimatedStyle(() => { const translateY = interpolate(progress.value, [0, 1], [screenHeight, 0]); return { transform: [{ translateY }], }; }); const handleOptionPress = (option: ActionSheetOption) => { if (!option.disabled) { feedback(option.destructive ? 'warning' : 'selection'); option.onPress(); onClose(); } }; // Dismissing is not a selection, so it stays silent. const handleBackdropPress = () => { onClose(); }; // Render null if the sheet is not supposed to be visible if (!isSheetVisible) { return null; } return ( {/* Header */} {(title || message) && ( {title && ( {title} )} {message && ( {message} )} )} {/* Options */} {options.map((option, index) => ( handleOptionPress(option)} disabled={option.disabled} activeOpacity={0.6} accessibilityRole='menuitem' accessibilityState={{ disabled: option.disabled }} accessibilityLabel={option.title} > {option.icon && ( {option.icon} )} {option.title} ))} {/* Cancel Button */} {cancelButtonTitle} ); } const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'flex-end', }, backdrop: { ...StyleSheet.absoluteFill, backgroundColor: 'rgba(0, 0, 0, 0.5)', }, backdropPressable: { flex: 1, }, sheet: { borderTopLeftRadius: BORDER_RADIUS, borderTopRightRadius: BORDER_RADIUS, maxHeight: '80%', elevation: 10, shadowColor: '#000', shadowOffset: { width: 0, height: -2, }, shadowOpacity: 0.25, shadowRadius: 10, }, header: { paddingHorizontal: 20, paddingTop: 20, paddingBottom: 16, alignItems: 'center', }, title: { fontSize: 18, fontWeight: '600', textAlign: 'center', marginBottom: 4, }, message: { fontSize: FONT_SIZE - 1, textAlign: 'center', lineHeight: 20, }, optionsContainer: { maxHeight: 300, }, option: { borderBottomWidth: StyleSheet.hairlineWidth, paddingHorizontal: 20, paddingVertical: 16, }, lastOption: { borderBottomWidth: 0, }, disabledOption: { opacity: 0.5, }, optionContent: { flexDirection: 'row', alignItems: 'center', }, optionIcon: { marginRight: 12, width: 24, height: 24, alignItems: 'center', justifyContent: 'center', }, optionText: { fontSize: FONT_SIZE, fontWeight: '500', flex: 1, }, cancelContainer: { borderTopWidth: StyleSheet.hairlineWidth, marginTop: 8, }, cancelButton: { paddingHorizontal: 20, paddingVertical: 16, alignItems: 'center', }, cancelText: { fontSize: FONT_SIZE, fontWeight: '600', }, }); // Hook for easier ActionSheet usage (No changes needed here) export function useActionSheet() { const [isVisible, setIsVisible] = React.useState(false); const [config, setConfig] = React.useState< Omit >({ options: [], }); const show = React.useCallback( (actionSheetConfig: Omit) => { setConfig(actionSheetConfig); setIsVisible(true); }, [] ); const hide = React.useCallback(() => { setIsVisible(false); }, []); const ActionSheetComponent = React.useMemo( () => , [isVisible, hide, config] ); return { show, hide, ActionSheet: ActionSheetComponent, isVisible, }; } ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { ActionSheet, useActionSheet } from '@/components/ui/action-sheet'; ``` ### Basic Usage with Hook ```tsx function MyComponent() { const { show, ActionSheet } = useActionSheet(); const handleShowActionSheet = () => { show({ title: 'Choose an option', options: [ { title: 'Edit', onPress: () => console.log('Edit pressed'), }, { title: 'Delete', onPress: () => console.log('Delete pressed'), destructive: true, }, ], }); }; return ( <> {ActionSheet} ); } ``` ### Direct Component Usage ```tsx setIsVisible(false)} title='Choose an action' message='Select one of the options below' options={[ { title: 'Share', onPress: handleShare, icon: , }, { title: 'Delete', onPress: handleDelete, destructive: true, }, ]} /> ``` ## Examples #### Default **Example:** A basic action sheet with multiple options and different styles ```tsx // components/demo/action-sheet/action-sheet-demo.tsx import { ActionSheet } from '@/components/ui/action-sheet'; import { Button } from '@/components/ui/button'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function ActionSheetDemo() { const [visible, setVisible] = useState(false); const options = [ { title: 'Edit', onPress: () => console.log('Edit pressed'), }, { title: 'Share', onPress: () => console.log('Share pressed'), }, { title: 'Delete', onPress: () => console.log('Delete pressed'), destructive: true, }, ]; return ( setVisible(false)} title='Choose an action' message='Select one of the options below' options={options} /> ); } ``` #### With Icons **Example:** An action sheet with icons next to each option ```tsx // components/demo/action-sheet/action-sheet-icons.tsx import { ActionSheet } from '@/components/ui/action-sheet'; import { Button } from '@/components/ui/button'; import { Icon } from '@/components/ui/icon'; import { Download, Edit, Share, Trash2 } from 'lucide-react-native'; import React, { useState } from 'react'; export function ActionSheetIcons() { const [visible, setVisible] = useState(false); const options = [ { title: 'Edit', onPress: () => console.log('Edit pressed'), icon: , }, { title: 'Share', onPress: () => console.log('Share pressed'), icon: , }, { title: 'Download', onPress: () => console.log('Download pressed'), icon: , }, { title: 'Delete', onPress: () => console.log('Delete pressed'), destructive: true, icon: , }, ]; return ( <> setVisible(false)} title='File Actions' options={options} /> ); } ``` #### Destructive Actions **Example:** An action sheet featuring destructive actions with appropriate styling ```tsx // components/demo/action-sheet/action-sheet-destructive.tsx import { ActionSheet } from '@/components/ui/action-sheet'; import { Button } from '@/components/ui/button'; import { Icon } from '@/components/ui/icon'; import { AlertTriangle, Trash2 } from 'lucide-react-native'; import React, { useState } from 'react'; export function ActionSheetDestructive() { const [visible, setVisible] = useState(false); const options = [ { title: 'Remove from Library', onPress: () => console.log('Remove from library'), destructive: true, }, { title: 'Delete Permanently', onPress: () => console.log('Delete permanently'), destructive: true, icon: , }, { title: 'Report Content', onPress: () => console.log('Report content'), destructive: true, icon: , }, ]; return ( <> setVisible(false)} title='Are you sure?' message='These actions cannot be undone' options={options} /> ); } ``` #### Disabled Options **Example:** An action sheet with some disabled options ```tsx // components/demo/action-sheet/action-sheet-disabled.tsx import { ActionSheet } from '@/components/ui/action-sheet'; import { Button } from '@/components/ui/button'; import { Icon } from '@/components/ui/icon'; import { Copy, Edit, Share, Trash2 } from 'lucide-react-native'; import React, { useState } from 'react'; export function ActionSheetDisabled() { const [visible, setVisible] = useState(false); const options = [ { title: 'Edit', onPress: () => console.log('Edit pressed'), icon: , }, { title: 'Copy', onPress: () => console.log('Copy pressed'), icon: , disabled: true, }, { title: 'Share', onPress: () => console.log('Share pressed'), icon: , disabled: true, }, { title: 'Delete', onPress: () => console.log('Delete pressed'), destructive: true, icon: , }, ]; return ( <> setVisible(false)} title='Document Actions' message='Some actions are not available' options={options} /> ); } ``` #### Custom Styling **Example:** An action sheet with custom styling and branding ```tsx // components/demo/action-sheet/action-sheet-styled.tsx import { ActionSheet } from '@/components/ui/action-sheet'; import { Button } from '@/components/ui/button'; import { Icon } from '@/components/ui/icon'; import { Bookmark, Heart, Send, Star } from 'lucide-react-native'; import React, { useState } from 'react'; export function ActionSheetStyled() { const [visible, setVisible] = useState(false); const options = [ { title: 'Add to Favorites', onPress: () => console.log('Add to favorites'), icon: , }, { title: 'Rate this Item', onPress: () => console.log('Rate item'), icon: , }, { title: 'Save for Later', onPress: () => console.log('Save for later'), icon: , }, { title: 'Share with Friends', onPress: () => console.log('Share with friends'), icon: , }, ]; return ( <> setVisible(false)} title='✨ Quick Actions' message='Choose how you want to interact with this item' options={options} cancelButtonTitle='Maybe Later' style={{ borderTopLeftRadius: 24, borderTopRightRadius: 24, }} /> ); } ``` #### Long Options List **Example:** An action sheet with many options that scrolls ```tsx // components/demo/action-sheet/action-sheet-long.tsx import { ActionSheet } from '@/components/ui/action-sheet'; import { Button } from '@/components/ui/button'; import { Icon } from '@/components/ui/icon'; import { Archive, Bookmark, Copy, Download, Edit, EyeOff, Flag, Heart, Pin, Send, Share, Star, Trash2, } from 'lucide-react-native'; import React, { useState } from 'react'; export function ActionSheetLong() { const [visible, setVisible] = useState(false); const options = [ { title: 'Edit Document', onPress: () => console.log('Edit'), icon: , }, { title: 'Share', onPress: () => console.log('Share'), icon: , }, { title: 'Download', onPress: () => console.log('Download'), icon: , }, { title: 'Copy Link', onPress: () => console.log('Copy link'), icon: , }, { title: 'Archive', onPress: () => console.log('Archive'), icon: , }, { title: 'Pin to Top', onPress: () => console.log('Pin'), icon: , }, { title: 'Add to Favorites', onPress: () => console.log('Favorite'), icon: , }, { title: 'Rate & Review', onPress: () => console.log('Rate'), icon: , }, { title: 'Bookmark', onPress: () => console.log('Bookmark'), icon: , }, { title: 'Send Message', onPress: () => console.log('Send message'), icon: , }, { title: 'Hide from Feed', onPress: () => console.log('Hide'), icon: , }, { title: 'Report Issue', onPress: () => console.log('Report'), icon: , }, { title: 'Delete', onPress: () => console.log('Delete'), destructive: true, icon: , }, ]; return ( <> setVisible(false)} title='All Actions' message='Scroll to see all available options' options={options} /> ); } ``` #### With Hook **Example:** Using the useActionSheet hook for easier management ```tsx // components/demo/action-sheet/action-sheet-hook.tsx import { useActionSheet } from '@/components/ui/action-sheet'; import { Button } from '@/components/ui/button'; import { Icon } from '@/components/ui/icon'; import { View } from '@/components/ui/view'; import { Camera, FileText, Image, Mic } from 'lucide-react-native'; import React from 'react'; export function ActionSheetHook() { const { show, ActionSheet } = useActionSheet(); const showMediaOptions = () => { show({ title: 'Add Media', message: 'Choose the type of media to add', options: [ { title: 'Take Photo', onPress: () => console.log('Take photo'), icon: , }, { title: 'Choose from Gallery', onPress: () => console.log('Choose from gallery'), icon: , }, { title: 'Record Audio', onPress: () => console.log('Record audio'), icon: , }, { title: 'Add Document', onPress: () => console.log('Add document'), icon: , }, ], }); }; const showConfirmation = () => { show({ title: 'Confirm Action', message: 'This action cannot be undone', options: [ { title: 'Yes, Continue', onPress: () => console.log('Confirmed'), destructive: true, }, ], }); }; return ( {ActionSheet} ); } ``` ## API Reference ### ActionSheet The main ActionSheet component. | Prop | Type | Default | Description | | ------------------- | --------------------- | ---------- | -------------------------------------------------- | | `visible` | `boolean` | - | Controls the visibility of the action sheet. | | `onClose` | `() => void` | - | Callback fired when the action sheet should close. | | `title` | `string` | - | Optional title displayed at the top. | | `message` | `string` | - | Optional message displayed below the title. | | `options` | `ActionSheetOption[]` | - | Array of options to display in the action sheet. | | `cancelButtonTitle` | `string` | `'Cancel'` | Text for the cancel button. | | `style` | `ViewStyle` | - | Additional styles for the action sheet container. | ### ActionSheetOption Configuration for individual action sheet options. | Prop | Type | Default | Description | | ------------- | ----------------- | ------- | --------------------------------------------------- | | `title` | `string` | - | The text to display for this option. | | `onPress` | `() => void` | - | Callback fired when this option is pressed. | | `destructive` | `boolean` | `false` | Whether this option should use destructive styling. | | `disabled` | `boolean` | `false` | Whether this option should be disabled. | | `icon` | `React.ReactNode` | - | Optional icon to display next to the title. | ### useActionSheet Hook A convenient hook for managing action sheet state. #### Returns | Property | Type | Description | | ------------- | ------------------------------------------------------------------ | ------------------------------------ | | `show` | `(config: Omit) => void` | Function to show the action sheet. | | `hide` | `() => void` | Function to hide the action sheet. | | `ActionSheet` | `React.ReactElement` | The ActionSheet component to render. | | `isVisible` | `boolean` | Current visibility state. | ## Platform Behavior ### iOS On iOS, the ActionSheet automatically uses the native `ActionSheetIOS` API, providing the familiar iOS action sheet experience with proper integration into the system UI. ### Android & Other Platforms On Android and other platforms, a custom implementation is used that mimics the native behavior with smooth animations and proper theming support. ## Accessibility The ActionSheet component follows accessibility best practices: - Proper focus management when opened/closed - Screen reader announcements for destructive actions - Keyboard navigation support where applicable - Respects system accessibility settings ## Animation The ActionSheet includes smooth animations: - Slide-up animation from bottom of screen - Backdrop fade-in/out - Spring-based animations for natural feel - Respects reduced motion preferences ## Theming The ActionSheet automatically adapts to your app's theme: - Uses theme colors for background, text, and borders - Supports both light and dark modes - Destructive actions use theme's destructive color - Disabled states respect theme opacity values # Alert Dialog > A modal dialog that interrupts the user with important content and expects a response. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/alert-dialog - Markdown: https://ui.ahmedbna.com/docs/components/alert-dialog.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/alert-dialog.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/alert-dialog.json - Install: `npx bna-ui add alert-dialog` - npm dependencies: `expo-haptics`, `lucide-react-native`, `react-native-reanimated`, `react-native-svg`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `text`, `view`, `card`, `useHaptics`, `icon`, `spinner`, `button` - Preview recording: https://demo.ahmedbna.com/0014-alert-dialog-demo.MP4 --- **Example:** A basic alert dialog with confirmation buttons. ```tsx // components/demo/alert-dialog/alert-dialog-demo.tsx import React from 'react'; import { AlertDialog, useAlertDialog } from '@/components/ui/alert-dialog'; import { Button } from '@/components/ui/button'; import { View } from '@/components/ui/view'; export function AlertDialogDemo() { const dialog = useAlertDialog(); return ( { console.log('Account deleted'); dialog.close(); }} onCancel={dialog.close} /> ); } ``` ## Installation ### CLI ```bash npx bna-ui add alert-dialog ``` ### Manual **1.** Install the following dependencies: This component uses `react-native-reanimated` for animations. ```bash npx expo install react-native-reanimated ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/alert-dialog.tsx import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { useColor } from '@/hooks/useColor'; import React, { useEffect } from 'react'; import { Modal, StyleSheet, TouchableWithoutFeedback, View, ViewStyle, } from 'react-native'; import Animated, { runOnJS, useAnimatedStyle, useSharedValue, withTiming, } from 'react-native-reanimated'; export type AlertDialogProps = { isVisible: boolean; onClose: () => void; title?: string; description?: string; children?: React.ReactNode; confirmText?: string; cancelText?: string; onConfirm?: () => void; onCancel?: () => void; dismissible?: boolean; showCancelButton?: boolean; style?: ViewStyle; }; // A simple card-like dialog overlay with fade-in animation similar to BottomSheet's backdrop export function AlertDialog({ isVisible, onClose, title, description, children, confirmText = 'OK', cancelText = 'Cancel', onConfirm, onCancel, dismissible = true, showCancelButton = true, style, }: AlertDialogProps) { const cardColor = useColor('card'); const [modalVisible, setModalVisible] = React.useState(false); const backdropOpacity = useSharedValue(0); const cardOpacity = useSharedValue(0); useEffect(() => { if (isVisible) { setModalVisible(true); backdropOpacity.value = withTiming(1, { duration: 250 }); cardOpacity.value = withTiming(1, { duration: 200 }); } else { backdropOpacity.value = withTiming(0, { duration: 250 }, (finished) => { if (finished) { runOnJS(setModalVisible)(false); } }); cardOpacity.value = withTiming(0, { duration: 200 }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isVisible]); const rBackdropStyle = useAnimatedStyle(() => ({ opacity: backdropOpacity.value, })); const rCardFadeStyle = useAnimatedStyle(() => ({ opacity: cardOpacity.value, })); const animateClose = () => { 'worklet'; backdropOpacity.value = withTiming(0, { duration: 300 }, (finished) => { if (finished) { runOnJS(onClose)(); } }); cardOpacity.value = withTiming(0, { duration: 200 }); }; const handleBackdropPress = () => { if (dismissible) { animateClose(); if (onCancel) onCancel(); } }; const handleCancel = () => { if (onCancel) onCancel(); animateClose(); }; const handleConfirm = () => { if (onConfirm) onConfirm(); animateClose(); }; return ( {/* Non-animated outer wrapper: handles rounded corners and clipping */} {/* Only fade the inner content */} {(title || description) && ( {title ? ( {title} ) : null} {description ? ( {description} ) : null} )} {children ? {children} : null} {showCancelButton && ( )} ); } const styles = StyleSheet.create({ backdrop: { flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.8)', alignItems: 'center', justifyContent: 'center', padding: 24, }, backdropTouchableArea: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, }, // Rounded corners and clipping consolidated here (non-animated) roundedWrapper: { width: '100%', borderRadius: 16, overflow: 'hidden', }, // Inner content can render freely (only opacity is animated) innerContent: { width: '100%', }, }); export function useAlertDialog() { const [isVisible, setIsVisible] = React.useState(false); const open = React.useCallback(() => setIsVisible(true), []); const close = React.useCallback(() => setIsVisible(false), []); const toggle = React.useCallback(() => setIsVisible((v) => !v), []); return { isVisible, open, close, toggle }; } ``` **3.** Update the import paths to match your project setup. This component depends on the `Card` and `Button` components, so make sure you have those installed as well. ## Usage The `useAlertDialog` hook is the recommended way to control the dialog's visibility state. ```tsx import { View } from 'react-native'; import { Button } from '@/components/ui/button'; import { AlertDialog, useAlertDialog } from '@/components/ui/alert-dialog'; export default function MyComponent() { const { isVisible, open, close } = useAlertDialog(); const handleConfirm = () => { // Handle the confirmation logic console.log('Action confirmed!'); }; return ( ); } ``` ## Examples #### Default **Example:** A basic alert dialog with confirmation buttons. ```tsx // components/demo/alert-dialog/alert-dialog-demo.tsx import React from 'react'; import { AlertDialog, useAlertDialog } from '@/components/ui/alert-dialog'; import { Button } from '@/components/ui/button'; import { View } from '@/components/ui/view'; export function AlertDialogDemo() { const dialog = useAlertDialog(); return ( { console.log('Account deleted'); dialog.close(); }} onCancel={dialog.close} /> ); } ``` #### Destructive Action **Example:** An alert dialog for destructive actions like delete. ```tsx // components/demo/alert-dialog/alert-dialog-destructive.tsx import React from 'react'; import { AlertDialog, useAlertDialog } from '@/components/ui/alert-dialog'; import { Button } from '@/components/ui/button'; import { View } from '@/components/ui/view'; export function AlertDialogDestructiveDemo() { const dialog = useAlertDialog(); return ( { console.log('Item deleted'); dialog.close(); }} onCancel={dialog.close} /> ); } ``` #### Custom Style **Example:** A custom styled alert dialog with a different appearance. ```tsx // components/demo/alert-dialog/alert-dialog-custom.tsx import React from 'react'; import { AlertDialog, useAlertDialog } from '@/components/ui/alert-dialog'; import { Button } from '@/components/ui/button'; import { View } from '@/components/ui/view'; import { Text } from '@/components/ui/text'; export function AlertDialogCustomDemo() { const dialog = useAlertDialog(); return ( { console.log('Continued'); dialog.close(); }} onCancel={dialog.close} style={{ borderRadius: 24 }} > Custom Content This dialog contains custom content instead of using the title and description props. ); } ``` ## API Reference ### AlertDialog The main component that renders the modal dialog. | Prop | Type | Default | Description | | ------------------ | ----------------- | ---------- | -------------------------------------------------------------------------- | | `isVisible` | `boolean` | - | **Required.** Controls the visibility of the dialog. | | `onClose` | `() => void` | - | **Required.** Callback function when the dialog is closed. | | `title` | `string` | - | The title displayed at the top of the dialog. | | `description` | `string` | - | The main content or description of the dialog. | | `children` | `React.ReactNode` | - | Custom React nodes to render inside the dialog's content area. | | `confirmText` | `string` | `'OK'` | The text for the confirmation button. | | `cancelText` | `string` | `'Cancel'` | The text for the cancellation button. | | `onConfirm` | `() => void` | - | Callback function when the confirmation button is pressed. | | `onCancel` | `() => void` | - | Callback function when the cancel button is pressed or backdrop is tapped. | | `dismissible` | `boolean` | `true` | If `true`, tapping the backdrop will close the dialog. | | `showCancelButton` | `boolean` | `true` | If `false`, the cancel button will not be rendered. | | `style` | `ViewStyle` | - | Custom styles to apply to the dialog's main container. | ### useAlertDialog A hook to manage the state of the `AlertDialog` component. | Return | Type | Description | | ----------- | ------------ | ----------------------------------------------------- | | `isVisible` | `boolean` | The current visibility state of the dialog. | | `open` | `() => void` | A function to set the dialog's visibility to `true`. | | `close` | `() => void` | A function to set the dialog's visibility to `false`. | | `toggle` | `() => void` | A function to toggle the dialog's visibility. | ## Accessibility The Alert Dialog component is designed with accessibility in mind to ensure a clear and interruptive user experience. - The modal nature of the component and the dark backdrop ensure that the user's attention is focused on the dialog content. - It uses standard, accessible components like `Button` and `Text` from the library. - The dialog can be dismissed by tapping the backdrop (if `dismissible` is true), providing an intuitive closing mechanism. # Alert > Display important messages to users with both visual inline alerts and native system alerts. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/alert - Markdown: https://ui.ahmedbna.com/docs/components/alert.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/alert.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/alert.json - Install: `npx bna-ui add alert` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `text`, `view` - Preview recording: https://demo.ahmedbna.com/0016-alert-demo.mov --- **Example:** A basic native alert with two buttons ```tsx // components/demo/alert/alert-demo.tsx import { createTwoButtonAlert } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import React from 'react'; export function AlertDemo() { const handleTwoButtonAlert = () => { createTwoButtonAlert({ title: 'Two Button Alert', message: 'This is a two-button alert example', buttons: [ { text: 'Cancel', onPress: () => console.log('Cancel Pressed'), style: 'cancel', }, { text: 'OK', onPress: () => console.log('OK Pressed'), }, ], }); }; return ; } ``` ## Installation ### CLI ```bash npx bna-ui add alert ``` ### Manual **1.** Copy and paste the following code into your project. ```tsx // components/ui/alert.tsx import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { BORDER_RADIUS } from '@/theme/globals'; import React from 'react'; import { Alert as RNAlert, TextStyle, ViewStyle } from 'react-native'; type AlertVariant = 'default' | 'destructive'; interface AlertProps { children: React.ReactNode; variant?: AlertVariant; style?: ViewStyle; } // Visual Alert Component (existing functionality) export function Alert({ children, variant = 'default', style }: AlertProps) { const borderColor = useColor('border'); const destructiveColor = useColor('destructive'); const backgroundColor = useColor('card'); return ( {children} ); } interface AlertTitleProps { children: React.ReactNode; style?: TextStyle; } export function AlertTitle({ children, style }: AlertTitleProps) { return ( {children} ); } interface AlertDescriptionProps { children: React.ReactNode; style?: TextStyle; } export function AlertDescription({ children, style }: AlertDescriptionProps) { return ( {children} ); } // Native Alert Functions interface AlertButton { text: string; onPress?: () => void; style?: 'default' | 'cancel' | 'destructive'; } interface NativeAlertOptions { title: string; message?: string; buttons?: AlertButton[]; cancelable?: boolean; } // Two-button native alert export const createTwoButtonAlert = (options: NativeAlertOptions) => { const { title, message, buttons } = options; const defaultButtons: AlertButton[] = [ { text: 'Cancel', onPress: () => console.log('Cancel Pressed'), style: 'cancel', }, { text: 'OK', onPress: () => console.log('OK Pressed'), }, ]; RNAlert.alert(title, message, buttons || defaultButtons); }; // Three-button native alert export const createThreeButtonAlert = (options: NativeAlertOptions) => { const { title, message, buttons } = options; const defaultButtons: AlertButton[] = [ { text: 'Ask me later', onPress: () => console.log('Ask me later pressed'), }, { text: 'Cancel', onPress: () => console.log('Cancel Pressed'), style: 'cancel', }, { text: 'OK', onPress: () => console.log('OK Pressed'), }, ]; RNAlert.alert(title, message, buttons || defaultButtons); }; // Generic native alert function export const showNativeAlert = (options: NativeAlertOptions) => { const { title, message, buttons, cancelable = true } = options; if (!buttons || buttons.length === 0) { // Simple alert with just OK button RNAlert.alert(title, message, [ { text: 'OK', onPress: () => console.log('OK Pressed'), }, ]); } else { RNAlert.alert(title, message, buttons, { cancelable }); } }; // Convenience functions for common alert types export const showSuccessAlert = ( title: string, message?: string, onOk?: () => void ) => { showNativeAlert({ title, message, buttons: [ { text: 'OK', onPress: onOk || (() => console.log('Success acknowledged')), }, ], }); }; export const showErrorAlert = ( title: string, message?: string, onOk?: () => void ) => { showNativeAlert({ title, message, buttons: [ { text: 'OK', onPress: onOk || (() => console.log('Error acknowledged')), style: 'destructive', }, ], }); }; export const showConfirmAlert = ( title: string, message?: string, onConfirm?: () => void, onCancel?: () => void ) => { showNativeAlert({ title, message, buttons: [ { text: 'Cancel', onPress: onCancel || (() => console.log('Cancelled')), style: 'cancel', }, { text: 'Confirm', onPress: onConfirm || (() => console.log('Confirmed')), }, ], }); }; // Export the React Native Alert for direct use export { RNAlert as NativeAlert }; ``` **2.** Update the import paths to match your project setup. ## Usage ```tsx import { Alert, AlertTitle, AlertDescription, showSuccessAlert, showErrorAlert, showConfirmAlert, showNativeAlert, } from '@/components/ui/alert'; ``` ### Visual Alert (Inline) ```tsx function MyComponent() { return ( Attention This is an important message that appears inline with your content. ); } ``` ### Native Alerts (System Dialogs) ```tsx function MyComponent() { const handleSuccess = () => { showSuccessAlert( 'Success!', 'Your action was completed successfully.', () => console.log('Success acknowledged') ); }; const handleConfirm = () => { showConfirmAlert( 'Confirm Action', 'Are you sure you want to proceed?', () => console.log('Confirmed'), () => console.log('Cancelled') ); }; return ( <> ); } ``` #### Visual Alerts #### Default **Example:** Inline visual alerts that appear within your content ```tsx // components/demo/alert/alert-visual-demo.tsx import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function AlertVisualDemo() { return ( Visual Alert This is a default visual alert that appears inline with your content. Visual alerts appear inline with your content, while native alerts appear as system dialogs on top of your app. ); } ``` #### Destructive **Example:** Destructive visual alerts for error messages ```tsx // components/demo/alert/alert-visual-destructive-demo.tsx import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function AlertVisualDestructiveDemo() { return ( Destructive Alert This is a destructive visual alert for error messages. Visual alerts appear inline with your content, while native alerts appear as system dialogs on top of your app. ); } ``` ### Native Alerts #### Default **Example:** Basic two-button native alert ```tsx // components/demo/alert/alert-demo.tsx import { createTwoButtonAlert } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import React from 'react'; export function AlertDemo() { const handleTwoButtonAlert = () => { createTwoButtonAlert({ title: 'Two Button Alert', message: 'This is a two-button alert example', buttons: [ { text: 'Cancel', onPress: () => console.log('Cancel Pressed'), style: 'cancel', }, { text: 'OK', onPress: () => console.log('OK Pressed'), }, ], }); }; return ; } ``` #### Three Button **Example:** Native alert with three button options ```tsx // components/demo/alert/alert-three-button-demo.tsx import { createThreeButtonAlert } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import React from 'react'; export function AlertThreeButtonDemo() { const handleThreeButtonAlert = () => { createThreeButtonAlert({ title: 'Three Button Alert', message: 'This is a three-button alert example', buttons: [ { text: 'Ask me later', onPress: () => console.log('Ask me later pressed'), }, { text: 'Cancel', onPress: () => console.log('Cancel Pressed'), style: 'cancel', }, { text: 'OK', onPress: () => console.log('OK Pressed'), }, ], }); }; return ( ); } ``` #### Success **Example:** Success alert with positive messaging ```tsx // components/demo/alert/alert-success-demo.tsx import { showSuccessAlert } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import React from 'react'; export function AlertSuccessDemo() { const handleSuccessAlert = () => { showSuccessAlert( 'Success!', 'Your action was completed successfully.', () => console.log('Success acknowledged') ); }; return ( ); } ``` #### Error **Example:** Error alert with destructive styling ```tsx // components/demo/alert/alert-error-demo.tsx import { showErrorAlert } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import React from 'react'; export function AlertErrorDemo() { const handleErrorAlert = () => { showErrorAlert('Error', 'Something went wrong. Please try again.', () => console.log('Error acknowledged') ); }; return ( ); } ``` #### Confirm **Example:** Confirmation alert for destructive actions ```tsx // components/demo/alert/alert-confirm-demo.tsx import { showConfirmAlert } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import React from 'react'; export function AlertConfirmDemo() { const handleConfirmAlert = () => { showConfirmAlert( 'Confirm Action', 'Are you sure you want to proceed with this action?', () => console.log('Action confirmed'), () => console.log('Action cancelled') ); }; return ( ); } ``` #### Custom **Example:** Custom native alert with multiple options ```tsx // components/demo/alert/alert-custom-demo.tsx import { showNativeAlert } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import React from 'react'; export function AlertCustomDemo() { const handleCustomAlert = () => { showNativeAlert({ title: 'Custom Alert', message: 'This is a custom native alert with multiple options', buttons: [ { text: 'Option 1', onPress: () => console.log('Option 1 selected'), }, { text: 'Option 2', onPress: () => console.log('Option 2 selected'), }, { text: 'Cancel', onPress: () => console.log('Cancelled'), style: 'cancel', }, ], }); }; return ; } ``` ## API Reference ### Visual Alert Components #### Alert The main visual alert container component. | Prop | Type | Default | Description | | ---------- | ---------------------------- | ----------- | ------------------------------------------ | | `children` | `React.ReactNode` | - | The content to display inside the alert. | | `variant` | `'default' \| 'destructive'` | `'default'` | The visual style variant of the alert. | | `style` | `ViewStyle` | - | Additional styles for the alert container. | #### AlertTitle Component for displaying the alert title. | Prop | Type | Default | Description | | ---------- | ----------------- | ------- | ------------------------------------- | | `children` | `React.ReactNode` | - | The title text to display. | | `style` | `TextStyle` | - | Additional styles for the title text. | #### AlertDescription Component for displaying the alert description. | Prop | Type | Default | Description | | ---------- | ----------------- | ------- | ------------------------------------------- | | `children` | `React.ReactNode` | - | The description text to display. | | `style` | `TextStyle` | - | Additional styles for the description text. | ### Native Alert Functions #### showSuccessAlert Display a success alert with positive messaging. ```tsx showSuccessAlert( title: string, message?: string, onOk?: () => void ) ``` #### showErrorAlert Display an error alert with destructive styling. ```tsx showErrorAlert( title: string, message?: string, onOk?: () => void ) ``` #### showConfirmAlert Display a confirmation alert for important actions. ```tsx showConfirmAlert( title: string, message?: string, onConfirm?: () => void, onCancel?: () => void ) ``` #### showNativeAlert Generic native alert function with full customization. ```tsx showNativeAlert({ title: string; message?: string; buttons?: AlertButton[]; cancelable?: boolean; }) ``` #### createTwoButtonAlert / createThreeButtonAlert Predefined alert functions for common use cases. ```tsx createTwoButtonAlert(options: NativeAlertOptions) createThreeButtonAlert(options: NativeAlertOptions) ``` ### AlertButton Configuration for individual alert buttons. | Prop | Type | Default | Description | | --------- | ---------------------------------------- | ----------- | ------------------------------------------ | | `text` | `string` | - | The text to display on the button. | | `onPress` | `() => void` | - | Callback fired when the button is pressed. | | `style` | `'default' \| 'cancel' \| 'destructive'` | `'default'` | The visual style of the button. | ## Platform Behavior ### Visual Alerts Visual alerts work consistently across all platforms and appear inline with your content. They're perfect for: - Form validation messages - Status updates - Non-critical notifications - Persistent information display ### Native Alerts Native alerts use the platform's built-in alert system: #### iOS - Uses `Alert.alert()` from React Native - Follows iOS Human Interface Guidelines - Integrates with system UI and accessibility features #### Android - Uses `Alert.alert()` from React Native - Follows Material Design principles - Adapts to device theme and user preferences ## Accessibility Both visual and native alerts follow accessibility best practices: ### Visual Alerts - Proper color contrast ratios - Semantic markup with appropriate roles - Screen reader friendly content structure - Respects system font size preferences ### Native Alerts - Automatic focus management - Screen reader announcements - Keyboard navigation support - System accessibility integration ## Best Practices ### When to Use Visual vs Native Alerts **Use Visual Alerts for:** - Form validation feedback - Status messages that don't require immediate action - Information that should remain visible while user continues working - Non-critical notifications **Use Native Alerts for:** - Critical actions that require user confirmation - Error messages that prevent continued use - Success confirmations for important actions - When you need to interrupt the user's workflow ### Button Configuration - **Cancel buttons**: Use `style: 'cancel'` - typically appears on the left (iOS) or as the negative action - **Destructive buttons**: Use `style: 'destructive'` - appears in red to indicate dangerous actions - **Default buttons**: Use `style: 'default'` or omit - for primary positive actions ### Message Guidelines - Keep titles short and descriptive - Use clear, actionable language - Provide enough context in the message - Make button text specific to the action ("Delete Photo" vs "OK") ## Theming The Alert components automatically adapt to your app's theme: ### Visual Alerts - Background colors from theme's card color - Border colors from theme's border color - Text colors from theme's text colors - Destructive variant uses theme's destructive color ### Native Alerts - Follow system theme automatically - Button colors adapt to platform conventions - Text rendering follows system preferences # Audio Player > A feature-rich audio player component with waveform visualization, playback controls, and seeking capabilities. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/audio-player - Markdown: https://ui.ahmedbna.com/docs/components/audio-player.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/audio-player.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/audio-player.json - Install: `npx bna-ui add audio-player` - npm dependencies: `expo-asset`, `expo-audio`, `expo-haptics`, `lucide-react-native`, `react-native-gesture-handler`, `react-native-reanimated`, `react-native-svg`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `useHaptics`, `text`, `view`, `icon`, `spinner`, `button`, `progress`, `audio-waveform` - Preview recording: https://demo.ahmedbna.com/0024-audio-player-demo.MP4 --- **Example:** A basic audio player with all features enabled ```tsx // components/demo/audio-player/audio-player-demo.tsx import { AudioPlayer } from '@/components/ui/audio-player'; export function AudioPlayerDemo() { // Sample audio URL - replace with your actual audio source const sampleAudioUrl = 'https://www.thesoundarchive.com/ringtones/old-phone-ringing.wav'; return ( { console.log('Playback status:', status); }} /> ); } ``` ## Installation ### CLI ```bash npx bna-ui add audio-player ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install expo-audio lucide-react-native ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/audio-player.tsx import { AudioWaveform } from '@/components/ui/audio-waveform'; import { Button } from '@/components/ui/button'; import { Progress } from '@/components/ui/progress'; import { Text } from '@/components/ui/text'; import { useColor } from '@/hooks/useColor'; import { BORDER_RADIUS } from '@/theme/globals'; import { AudioSource, useAudioPlayer } from 'expo-audio'; import { Pause, Play, RotateCcw, Square } from 'lucide-react-native'; import { useCallback, useEffect, useState } from 'react'; import { StyleSheet, View, ViewStyle } from 'react-native'; export interface AudioPlayerProps { source: AudioSource; style?: ViewStyle; showControls?: boolean; showWaveform?: boolean; showTimer?: boolean; showProgressBar?: boolean; autoPlay?: boolean; onPlaybackStatusUpdate?: (status: any) => void; } export function AudioPlayer({ source, style, showControls = true, showWaveform = true, showTimer = true, showProgressBar = true, autoPlay = false, onPlaybackStatusUpdate, }: AudioPlayerProps) { const player = useAudioPlayer(source); const [duration, setDuration] = useState(0); const [position, setPosition] = useState(0); const [isSeeking, setIsSeeking] = useState(false); // Enhanced waveform data - more bars for smoother visualization const [waveformData] = useState( Array.from({ length: 60 }, (_, i) => { // Create more varied and realistic waveform pattern const base1 = Math.sin((i / 60) * Math.PI * 6) * 0.4 + 0.5; const base2 = Math.sin((i / 60) * Math.PI * 2.5) * 0.3 + 0.4; const noise = (Math.random() - 0.5) * 0.25; const peak = Math.random() < 0.15 ? Math.random() * 0.4 : 0; // Occasional peaks return Math.max(0.15, Math.min(0.95, (base1 + base2) / 2 + noise + peak)); }) ); // Theme colors const redColor = useColor('destructive'); const secondaryColor = useColor('secondary'); const textColor = useColor('text'); const mutedColor = useColor('textMuted'); useEffect(() => { if (autoPlay && player.isLoaded && !player.playing) { player.play(); } }, [autoPlay, player.isLoaded]); useEffect(() => { const interval = setInterval(() => { if (player.isLoaded && !isSeeking) { const currentTime = player.currentTime || 0; const totalDuration = player.duration || 0; setDuration(totalDuration); setPosition(currentTime); // Check if the audio finished if (currentTime >= totalDuration && totalDuration > 0) { player.seekTo(0); player.pause(); // Ensure it's paused } if (onPlaybackStatusUpdate) { onPlaybackStatusUpdate({ isLoaded: player.isLoaded, playing: player.playing, duration: totalDuration, position: currentTime, }); } } }, 100); return () => clearInterval(interval); }, [player, onPlaybackStatusUpdate, isSeeking]); const handlePlayPause = () => { if (player.playing) { player.pause(); } else { player.play(); } }; const handleBackFiveSeconds = () => { const newPosition = Math.max(0, position - 5); seekToPosition(newPosition); }; const handleRestart = () => { seekToPosition(0); }; // Unified seeking function const seekToPosition = useCallback( (newPosition: number) => { if (player.isLoaded && duration > 0) { const clampedPosition = Math.max(0, Math.min(duration, newPosition)); player.seekTo(clampedPosition); setPosition(clampedPosition); } }, [player, duration] ); // Handle waveform seeking const handleWaveformSeek = useCallback( (seekPercentage: number) => { if (duration > 0) { const newPosition = (seekPercentage / 100) * duration; seekToPosition(newPosition); } }, [duration, seekToPosition] ); // Handle progress bar seeking const handleProgressSeek = useCallback( (progressValue: number) => { if (duration > 0) { const newPosition = (progressValue / 100) * duration; seekToPosition(newPosition); } }, [duration, seekToPosition] ); // Handle seeking start/end for smooth updates const handleSeekStart = useCallback(() => { setIsSeeking(true); }, []); const handleSeekEnd = useCallback(() => { setIsSeeking(false); }, []); const formatTime = (seconds: number) => { const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, '0')}`; }; const progressPercentage = duration > 0 ? (position / duration) * 100 : 0; return ( {/* Waveform Visualization with seeking capability */} {showWaveform && ( )} {/* Interactive Progress Bar */} {showProgressBar && ( )} {/* Controls */} {showControls && ( )} {/* Timer */} {showTimer && ( {formatTime(position)} / {formatTime(duration)} )} {/* Loading State */} {!player.isLoaded && ( Loading audio... )} ); } const styles = StyleSheet.create({ container: { borderRadius: BORDER_RADIUS, padding: 16, margin: 8, }, waveformContainer: { alignItems: 'center', marginBottom: 12, }, progressContainer: { marginBottom: 12, paddingHorizontal: 4, }, progressBar: { // Additional styling if needed }, controlsContainer: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 12, marginBottom: 8, }, controlButton: { width: 40, height: 40, }, playButton: { width: 56, height: 56, }, timerContainer: { alignItems: 'center', }, loadingContainer: { alignItems: 'center', padding: 8, }, }); ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { AudioPlayer } from '@/components/ui/audio-player'; ``` ### Basic Usage ```tsx function MyComponent() { return ( ); } ``` ### With Local File ```tsx { console.log('Playback status:', status); }} /> ``` ### Minimal Player ```tsx ``` ## Examples #### Default **Example:** A complete audio player with all features enabled ```tsx // components/demo/audio-player/audio-player-demo.tsx import { AudioPlayer } from '@/components/ui/audio-player'; export function AudioPlayerDemo() { // Sample audio URL - replace with your actual audio source const sampleAudioUrl = 'https://www.thesoundarchive.com/ringtones/old-phone-ringing.wav'; return ( { console.log('Playback status:', status); }} /> ); } ``` #### Minimal **Example:** A minimal audio player with only essential controls ```tsx // components/demo/audio-player/audio-player-minimal.tsx import { AudioPlayer } from '@/components/ui/audio-player'; export function AudioPlayerMinimal() { const sampleAudioUrl = 'https://www.thesoundarchive.com/ringtones/old-phone-ringing.wav'; return ( ); } ``` #### Waveform Only **Example:** Audio player focused on waveform visualization ```tsx // components/demo/audio-player/audio-player-waveform.tsx import { AudioPlayer } from '@/components/ui/audio-player'; export function AudioPlayerWaveform() { const sampleAudioUrl = 'https://www.thesoundarchive.com/ringtones/old-phone-ringing.wav'; return ( ); } ``` #### Custom Styling **Example:** An audio player with custom styling and theming ```tsx // components/demo/audio-player/audio-player-styled.tsx import { AudioPlayer } from '@/components/ui/audio-player'; import { useColor } from '@/hooks/useColor'; export function AudioPlayerStyled() { const blue = useColor('indigo'); const sampleAudioUrl = 'https://www.thesoundarchive.com/ringtones/old-phone-ringing.wav'; return ( ); } ``` #### Auto Play **Example:** Audio player that starts playing automatically when loaded ```tsx // components/demo/audio-player/audio-player-autoplay.tsx import { AudioPlayer } from '@/components/ui/audio-player'; export function AudioPlayerAutoplay() { const sampleAudioUrl = 'https://www.thesoundarchive.com/ringtones/old-phone-ringing.wav'; return ( { if (status.isLoaded && status.playing) { console.log('Auto-playing audio'); } }} /> ); } ``` #### Progress Bar Only **Example:** Audio player using only a progress bar for seeking ```tsx // components/demo/audio-player/audio-player-progress.tsx import { AudioPlayer } from '@/components/ui/audio-player'; export function AudioPlayerProgress() { const sampleAudioUrl = 'https://www.thesoundarchive.com/ringtones/old-phone-ringing.wav'; return ( ); } ``` #### Audio player Music **Example:** Audio player with music-focused UI including album art and track info ```tsx // components/demo/audio-player/audio-player-music.tsx import { AudioPlayer } from '@/components/ui/audio-player'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { Heart, MoreHorizontal, Shuffle, SkipBack, SkipForward, } from 'lucide-react-native'; import React, { useState } from 'react'; import { Image, StyleSheet } from 'react-native'; export function AudioPlayerMusic() { const [isLiked, setIsLiked] = useState(false); const sampleAudioUrl = 'https://www.thesoundarchive.com/ringtones/old-phone-ringing.wav'; return ( {/* Album Art */} {/* Track Information */} Midnight Waves Ocean Sounds Orchestra {/* Action Buttons */} {/* Audio Player */} {/* Additional Controls */} ); } const styles = StyleSheet.create({ musicPlayer: { backgroundColor: '#F2F2F7', borderRadius: 16, padding: 20, shadowColor: '#000', shadowOffset: { width: 0, height: 4, }, shadowOpacity: 0.1, shadowRadius: 12, elevation: 8, }, albumArtContainer: { alignItems: 'center', marginBottom: 16, }, albumArt: { width: 120, height: 120, borderRadius: 12, }, trackInfo: { alignItems: 'center', marginBottom: 12, }, trackTitle: { fontSize: 18, fontWeight: '600', marginBottom: 4, color: '#1a1a1a', }, artistName: { fontSize: 14, color: '#666', }, actionButtons: { flexDirection: 'row', justifyContent: 'center', gap: 8, marginBottom: 16, }, actionButton: { width: 36, height: 36, }, playerContainer: { backgroundColor: 'transparent', margin: 0, padding: 0, marginBottom: 16, }, additionalControls: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, }, controlButton: { width: 36, height: 36, }, spacer: { width: 40, }, }); ``` ## API Reference ### AudioPlayer The main AudioPlayer component. | Prop | Type | Default | Description | | ------------------------ | ----------------------- | ------- | --------------------------------------------------- | | `source` | `AudioSource` | - | The audio source to play (URI or local file). | | `style` | `ViewStyle` | - | Additional styles for the player container. | | `showControls` | `boolean` | `true` | Whether to show playback controls. | | `showWaveform` | `boolean` | `true` | Whether to show the waveform visualization. | | `showTimer` | `boolean` | `true` | Whether to show the current time and duration. | | `showProgressBar` | `boolean` | `true` | Whether to show the progress bar. | | `autoPlay` | `boolean` | `false` | Whether to start playing automatically when loaded. | | `onPlaybackStatusUpdate` | `(status: any) => void` | - | Callback fired when playback status changes. | ### AudioSource The audio source configuration. ```tsx type AudioSource = | { uri: string; } | number; // For local files using require() ``` ### Playback Status The status object passed to `onPlaybackStatusUpdate`: ```tsx type PlaybackStatus = { isLoaded: boolean; playing: boolean; duration: number; position: number; }; ``` ## Features ### Waveform Visualization The audio player includes an interactive waveform that: - Shows audio amplitude visualization with 60 bars - Displays playback progress with visual feedback - Supports seeking by tapping/clicking on the waveform - Animates smoothly during playback - Uses theme colors for active/inactive states ### Playback Controls Standard playback controls include: - **Play/Pause**: Toggle audio playback - **Back 5 seconds**: Skip backward 5 seconds - **Restart**: Return to the beginning of the track ### Interactive Progress Bar An alternative to waveform seeking: - Shows current playback position - Allows seeking by dragging or tapping - Smooth visual feedback during interaction - Respects theme colors ### Timer Display Shows current position and total duration: - Format: `MM:SS / MM:SS` - Updates in real-time during playback - Uses muted text color from theme ## Platform Support The AudioPlayer works across all platforms supported by Expo: - **iOS**: Native audio playback with hardware control integration - **Android**: Optimized audio engine with proper lifecycle management - **Web**: HTML5 audio with fallback support ## Accessibility The AudioPlayer component follows accessibility best practices: - Screen reader announcements for control actions - Proper button labeling and roles - Keyboard navigation support (web) - Respects system accessibility settings - High contrast support for visually impaired users ## Performance The component is optimized for performance: - Efficient waveform rendering with limited update frequency - Smooth animations using native drivers where possible - Memory-efficient audio loading and cleanup - Minimal re-renders during playback ## Theming The AudioPlayer automatically adapts to your app's theme: - Uses theme colors for backgrounds, text, and accents - Supports both light and dark modes - Destructive color for the main play button - Muted colors for inactive states - Customizable through theme configuration ## Advanced Usage ### Custom Playback Status Handling ```tsx { if (status.isLoaded && status.playing) { // Track listening analytics analytics.track('audio_playing', { position: status.position, duration: status.duration, }); } }} /> ``` ### Responsive Design ```tsx ``` ## Troubleshooting ### Audio Not Loading - Ensure the audio source URL is accessible - Check network connectivity - Verify audio format is supported (MP3, AAC, WAV) - Check for CORS issues on web platform ### Performance Issues - Reduce waveform bar count for lower-end devices - Disable animations on older devices - Use lower quality audio files for better loading times ### Seeking Issues - Ensure audio file supports seeking (not all streaming formats do) - Check if the audio source provides duration metadata - Verify the audio file is not corrupted # Audio Recorder > A comprehensive audio recording component with real-time waveform visualization, quality settings, and built-in playback. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/audio-recorder - Markdown: https://ui.ahmedbna.com/docs/components/audio-recorder.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/audio-recorder.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/audio-recorder.json - Install: `npx bna-ui add audio-recorder` - npm dependencies: `expo-asset`, `expo-audio`, `expo-haptics`, `lucide-react-native`, `react-native-gesture-handler`, `react-native-reanimated`, `react-native-svg`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `useHaptics`, `text`, `view`, `icon`, `spinner`, `button`, `progress`, `audio-waveform`, `audio-player` - Preview recording: https://demo.ahmedbna.com/0031-audio-recorder-demo.MP4 --- **Example:** A full-featured audio recorder with real-time waveform and playback ```tsx // components/demo/audio-recorder/audio-recorder-demo.tsx import { AudioRecorder } from '@/components/ui/audio-recorder'; export function AudioRecorderDemo() { const handleRecordingComplete = (uri: string) => { console.log('Recording saved to:', uri); }; const handleRecordingStart = () => { console.log('Recording started'); }; const handleRecordingStop = () => { console.log('Recording stopped'); }; return ( ); } ``` ## Installation ### CLI ```bash npx bna-ui add audio-recorder ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install expo-audio lucide-react-native react-native-reanimated ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/audio-recorder.tsx import { AudioPlayer } from '@/components/ui/audio-player'; import { AudioWaveform } from '@/components/ui/audio-waveform'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { useColor } from '@/hooks/useColor'; import { BORDER_RADIUS } from '@/theme/globals'; import { AudioModule, RecordingOptions, RecordingPresets, useAudioRecorder, } from 'expo-audio'; import { Circle, Download, Mic, Square, Trash2 } from 'lucide-react-native'; import React, { useEffect, useRef, useState } from 'react'; import { Alert, Platform, StyleSheet, View, ViewStyle } from 'react-native'; import Animated, { cancelAnimation, Easing, useAnimatedStyle, useSharedValue, withRepeat, withTiming, } from 'react-native-reanimated'; export interface AudioRecorderProps { style?: ViewStyle; quality?: 'high' | 'low'; showWaveform?: boolean; showTimer?: boolean; maxDuration?: number; // in seconds onRecordingComplete?: (uri: string) => void; onRecordingStart?: () => void; onRecordingStop?: () => void; customRecordingOptions?: RecordingOptions; } export function AudioRecorder({ style, quality = 'high', showWaveform = true, showTimer = true, maxDuration, onRecordingComplete, onRecordingStart, onRecordingStop, customRecordingOptions, }: AudioRecorderProps) { const recordingOptions = customRecordingOptions || (quality === 'high' ? RecordingPresets.HIGH_QUALITY : RecordingPresets.LOW_QUALITY); const recorder = useAudioRecorder(recordingOptions); const [permissionGranted, setPermissionGranted] = useState(false); const [duration, setDuration] = useState(0); const [recordingUri, setRecordingUri] = useState(null); const [isRecording, setIsRecording] = useState(false); // Waveform data for real-time visualization const [waveformData, setWaveformData] = useState( Array.from({ length: 30 }, () => 0.2) ); // Theme colors const primaryColor = useColor('primary'); const secondaryColor = useColor('secondary'); const textColor = useColor('text'); const mutedColor = useColor('textMuted'); const redColor = useColor('red'); const greenColor = useColor('green'); // Animation values using react-native-reanimated const recordingPulse = useSharedValue(1); const durationInterval = useRef | null>(null); const meteringInterval = useRef | null>(null); // Request permissions on mount useEffect(() => { (async () => { try { const status = await AudioModule.requestRecordingPermissionsAsync(); setPermissionGranted(status.granted); if (!status.granted) { Alert.alert( 'Permission Required', 'Please grant microphone permission to record audio.', [{ text: 'OK' }] ); } } catch (error) { console.error('Error requesting permissions:', error); setPermissionGranted(false); } })(); }, []); // Recording pulse animation using react-native-reanimated useEffect(() => { if (isRecording) { // Start the pulse animation recordingPulse.value = withRepeat( withTiming(1.2, { duration: 600, easing: Easing.inOut(Easing.ease) }), -1, // Infinite loop true // Reverse the animation (yoyo effect) ); } else { // Stop the animation and reset the scale cancelAnimation(recordingPulse); recordingPulse.value = withTiming(1, { duration: 300 }); } return () => { // Ensure animation is cancelled on unmount cancelAnimation(recordingPulse); }; }, [isRecording, recordingPulse]); // Create animated style for the record button const animatedRecordButtonStyle = useAnimatedStyle(() => { return { transform: [{ scale: recordingPulse.value }], }; }); // Real-time waveform updates during recording useEffect(() => { if (isRecording) { meteringInterval.current = setInterval(async () => { try { // Try to get metering data from recorder const status = recorder.getStatus(); let level = 0.3; // Default fallback level if (status && typeof status.metering === 'number') { // Convert dB to normalized value (typical range -160 to 0 dB) const dbLevel = status.metering; level = Math.max(0.1, Math.min(1.0, (dbLevel + 50) / 50)); } else { // Generate more realistic simulated audio levels const time = Date.now() / 1000; const baseLevel = 0.3 + Math.sin(time * 2) * 0.2; // Sine wave base const variation = (Math.random() - 0.5) * 0.4; // Random variation const spike = Math.random() < 0.1 ? Math.random() * 0.3 : 0; // Occasional spikes level = Math.max(0.1, Math.min(0.9, baseLevel + variation + spike)); } // Update waveform data by shifting array and adding new value setWaveformData((prevData) => { const newData = [...prevData.slice(1), level]; return newData; }); } catch (error) { console.log('Using simulated audio data'); // Fallback to realistic simulated data const time = Date.now() / 1000; const baseLevel = 0.4 + Math.sin(time * 3) * 0.2; const noise = (Math.random() - 0.5) * 0.3; const level = Math.max(0.15, Math.min(0.85, baseLevel + noise)); setWaveformData((prevData) => [...prevData.slice(1), level]); } }, 80); // Update every 80ms for smooth animation return () => { if (meteringInterval.current) { clearInterval(meteringInterval.current); meteringInterval.current = null; } }; } else { // Reset to quiet state when not recording setWaveformData(Array.from({ length: 30 }, () => 0.2)); if (meteringInterval.current) { clearInterval(meteringInterval.current); meteringInterval.current = null; } } }, [isRecording, recorder]); // Auto-stop recording when max duration is reached useEffect(() => { if (maxDuration && duration >= maxDuration && isRecording) { handleStopRecording(); } }, [duration, maxDuration, isRecording]); const startDurationTimer = () => { setDuration(0); durationInterval.current = setInterval(() => { setDuration((prev) => prev + 0.1); }, 100); }; const stopDurationTimer = () => { if (durationInterval.current) { clearInterval(durationInterval.current); durationInterval.current = null; } }; const handleStartRecording = async () => { if (!permissionGranted) { Alert.alert( 'Permission Required', 'Microphone permission is required to record audio.' ); return; } try { console.log('Starting recording...'); setRecordingUri(null); setIsRecording(true); startDurationTimer(); // Enable metering in recording options const meteringOptions = { ...recordingOptions, isMeteringEnabled: true, }; await recorder.prepareToRecordAsync(meteringOptions); await recorder.record(); onRecordingStart?.(); console.log('Recording started successfully'); } catch (error) { console.error('Error starting recording:', error); setIsRecording(false); stopDurationTimer(); Alert.alert('Error', 'Failed to start recording. Please try again.'); } }; const handleStopRecording = async () => { try { console.log('Stopping recording...'); setIsRecording(false); stopDurationTimer(); await recorder.stop(); const uri = recorder.uri; console.log('Recording stopped, URI:', uri); if (uri) { setRecordingUri(uri); onRecordingComplete?.(uri); } onRecordingStop?.(); } catch (error) { console.error('Error stopping recording:', error); Alert.alert('Error', 'Failed to stop recording. Please try again.'); } }; const handleDeleteRecording = () => { Alert.alert( 'Delete Recording', 'Are you sure you want to delete this recording?', [ { text: 'Cancel', style: 'cancel' }, { text: 'Delete', style: 'destructive', onPress: () => { setRecordingUri(null); setDuration(0); }, }, ] ); }; const handleSaveRecording = () => { if (recordingUri && onRecordingComplete) { onRecordingComplete(recordingUri); } }; const formatTime = (seconds: number) => { const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); const centisecs = Math.floor((seconds % 1) * 100); return `${mins}:${secs.toString().padStart(2, '0')}.${centisecs .toString() .padStart(2, '0')}`; }; if (!permissionGranted) { return ( Microphone permission is required to record audio. ); } return ( {recordingUri && !isRecording ? ( ) : ( {/* Recording Status */} {isRecording ? ( Recording ) : ( )} {/* Waveform Visualization */} {showWaveform && ( )} {/* Timer */} {showTimer && ( {formatTime(duration)} {maxDuration && ( Max: {formatTime(maxDuration)} )} )} {/* Controls */} {!isRecording && !recordingUri && ( )} {isRecording && ( )} )} ); } const styles = StyleSheet.create({ container: { borderRadius: BORDER_RADIUS, padding: 20, alignItems: 'center', }, recordingStatus: { height: 36, }, recordingIndicator: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', }, waveformContainer: { alignItems: 'center', marginBottom: 16, }, timerContainer: { alignItems: 'center', marginBottom: 20, }, controlsContainer: { alignItems: 'center', marginBottom: 12, }, recordButton: { width: 80, height: 80, borderRadius: 40, }, stopButton: { width: 80, height: 80, borderRadius: 40, }, playbackControls: { flexDirection: 'row', alignItems: 'center', gap: 16, marginTop: 16, }, controlButton: { width: 48, height: 48, }, saveButton: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 24, }, }); ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { AudioRecorder } from '@/components/ui/audio-recorder'; ``` ### Basic Usage ```tsx function MyComponent() { return ( { console.log('Recording saved to:', uri); }} /> ); } ``` ### With Custom Settings ```tsx console.log('Recording started')} onRecordingStop={() => console.log('Recording stopped')} onRecordingComplete={(uri) => { // Handle the recorded audio file handleAudioFile(uri); }} /> ``` ### Low Quality for Voice Notes ```tsx ``` ## Examples #### Default **Example:** A complete audio recorder with all features enabled ```tsx // components/demo/audio-recorder/audio-recorder-demo.tsx import { AudioRecorder } from '@/components/ui/audio-recorder'; export function AudioRecorderDemo() { const handleRecordingComplete = (uri: string) => { console.log('Recording saved to:', uri); }; const handleRecordingStart = () => { console.log('Recording started'); }; const handleRecordingStop = () => { console.log('Recording stopped'); }; return ( ); } ``` #### Voice Notes **Example:** Optimized recorder for quick voice notes with time limit ```tsx // components/demo/audio-recorder/audio-recorder-voice.tsx import { AudioRecorder } from '@/components/ui/audio-recorder'; export function AudioRecorderVoice() { const handleRecordingComplete = (uri: string) => { // Here you could add the voice note to a list or send it console.log('Voice note saved:', uri); }; return ( ); } ``` #### High Quality **Example:** High-quality recorder for music or professional audio ```tsx // components/demo/audio-recorder/audio-recorder-hq.tsx import { AudioRecorder } from '@/components/ui/audio-recorder'; import { RecordingPresets } from 'expo-audio'; export function AudioRecorderHQ() { const handleRecordingComplete = (uri: string) => { console.log('HQ recording saved:', uri); }; return ( ); } ``` #### Minimal **Example:** Minimal recorder without waveform visualization ```tsx // components/demo/audio-recorder/audio-recorder-minimal.tsx import { AudioRecorder } from '@/components/ui/audio-recorder'; export function AudioRecorderMinimal() { const handleRecordingComplete = (uri: string) => { console.log('Your audio has been recorded.', uri); }; return ( ); } ``` #### Custom Styled **Example:** Audio recorder with custom styling and branding ```tsx // components/demo/audio-recorder/audio-recorder-styled.tsx import { AudioRecorder } from '@/components/ui/audio-recorder'; export function AudioRecorderStyled() { const handleRecordingComplete = (uri: string) => { console.log('🎵 Recording Complete', uri); }; return ( ); } ``` #### Callbacks Recorder **Example:** Recorder with comprehensive callback handling ```tsx // components/demo/audio-recorder/audio-recorder-callbacks.tsx import { AudioRecorder } from '@/components/ui/audio-recorder'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useState } from 'react'; export function AudioRecorderCallbacks() { const [status, setStatus] = useState('Ready to record'); const [recordingCount, setRecordingCount] = useState(0); const handleRecordingStart = () => { setStatus('🔴 Recording in progress...'); console.log('Recording started'); }; const handleRecordingStop = () => { setStatus('✅ Recording stopped'); console.log('Recording stopped'); }; const handleRecordingComplete = (uri: string) => { setRecordingCount((prev) => prev + 1); setStatus(`📁 Recording #${recordingCount + 1} saved`); // Reset status after 3 seconds setTimeout(() => setStatus('Ready to record'), 3000); }; const getOrdinalSuffix = (num: number) => { const lastDigit = num % 10; const lastTwoDigits = num % 100; if (lastTwoDigits >= 11 && lastTwoDigits <= 13) return 'th'; if (lastDigit === 1) return 'st'; if (lastDigit === 2) return 'nd'; if (lastDigit === 3) return 'rd'; return 'th'; }; return ( Status: {status} {recordingCount > 0 && ( Total recordings: {recordingCount} )} ); } ``` #### Cloud Integration Recorder **Example:** Recorder with cloud storage integration for saving recordings ```tsx // components/demo/audio-recorder/audio-recorder-cloud.tsx import { AudioRecorder } from '@/components/ui/audio-recorder'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; import { ActivityIndicator } from 'react-native'; export function AudioRecorderCloud() { const [uploading, setUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(0); const simulateCloudUpload = async (uri: string): Promise => { return new Promise((resolve) => { setUploading(true); setUploadProgress(0); const interval = setInterval(() => { setUploadProgress((prev) => { if (prev >= 100) { clearInterval(interval); setUploading(false); resolve( `https://cloud-storage.example.com/audio/${Date.now()}.m4a` ); return 100; } return prev + 10; }); }, 200); }); }; const handleRecordingComplete = async (uri: string) => { try { const cloudUrl = await simulateCloudUpload(uri); console.log(`Recording uploaded to cloud storage!\n\nURL: ${cloudUrl}`); setUploadProgress(0); } catch (error) { console.log('Failed to upload recording to cloud storage.'); setUploading(false); setUploadProgress(0); } }; return ( {uploading && ( Uploading... {uploadProgress}% )} ); } ``` #### Interview Mode Recorder **Example:** Interview mode recorder ```tsx // components/demo/audio-recorder/audio-recorder-interview.tsx import { AudioRecorder } from '@/components/ui/audio-recorder'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { RecordingPresets } from 'expo-audio'; import React, { useState } from 'react'; import { Alert, StyleSheet } from 'react-native'; export function AudioRecorderInterview() { const [interviewTitle, setInterviewTitle] = useState(''); const [isRecording, setIsRecording] = useState(false); const handleRecordingStart = () => { setIsRecording(true); const title = `Interview ${new Date().toLocaleDateString()}`; setInterviewTitle(title); }; const handleRecordingStop = () => { setIsRecording(false); }; const handleRecordingComplete = (uri: string) => { Alert.alert( '🎤 Interview Complete', `"${interviewTitle}" has been recorded and saved.\n\nDuration: Available in file metadata\nQuality: High (48kHz, Stereo)`, [{ text: 'Save & Exit' }] ); }; return ( {interviewTitle || 'Ready for Interview'} {isRecording ? '🔴 LIVE' : '⏸️ READY'} Maximum duration: 2 hours • High quality stereo recording ); } const styles = StyleSheet.create({ interviewHeader: { marginBottom: 16, alignItems: 'center', }, interviewTitle: { marginBottom: 8, textAlign: 'center', }, statusBadge: { paddingHorizontal: 12, paddingVertical: 4, borderRadius: 12, backgroundColor: '#f3f4f6', }, recordingBadge: { backgroundColor: '#fef2f2', }, statusText: { fontSize: 12, fontWeight: '600', color: '#6b7280', }, recordingText: { color: '#dc2626', }, }); ``` ## API Reference ### AudioRecorder The main AudioRecorder component. | Prop | Type | Default | Description | | ------------------------ | ----------------------- | -------- | ----------------------------------------------------- | | `style` | `ViewStyle` | - | Additional styles for the recorder container. | | `quality` | `'high' \| 'low'` | `'high'` | Recording quality preset. | | `showWaveform` | `boolean` | `true` | Whether to show real-time waveform visualization. | | `showTimer` | `boolean` | `true` | Whether to show the recording timer. | | `maxDuration` | `number` | - | Maximum recording duration in seconds. | | `onRecordingComplete` | `(uri: string) => void` | - | Callback fired when recording is completed and saved. | | `onRecordingStart` | `() => void` | - | Callback fired when recording starts. | | `onRecordingStop` | `() => void` | - | Callback fired when recording stops. | | `customRecordingOptions` | `RecordingOptions` | - | Custom recording options to override presets. | ### Recording Quality Presets The component includes two built-in quality presets: #### High Quality - Sample Rate: 44,100 Hz - Bit Rate: 128,000 bps - Channels: 2 (Stereo) - Format: AAC - Best for: Music, professional recordings #### Low Quality - Sample Rate: 22,050 Hz - Bit Rate: 64,000 bps - Channels: 1 (Mono) - Format: AAC - Best for: Voice notes, quick recordings ### Custom Recording Options ```tsx type RecordingOptions = { sampleRate?: number; bitRate?: number; numberOfChannels?: number; format?: string; isMeteringEnabled?: boolean; }; ``` ## Features ### Real-time Waveform Visualization The recorder shows live audio levels: - 30 bars representing real-time audio amplitude - Smooth animation updates every 80ms - Uses actual microphone input when available - Falls back to realistic simulated data - Color changes based on recording state ### Recording Controls Intuitive recording interface: - **Record Button**: Large, prominent button to start recording - **Stop Button**: Clear square icon to stop recording - **Animated Feedback**: Pulsing animation during recording - **Visual Indicators**: Recording status with red dot ### Built-in Playback After recording, users can: - **Play/Pause**: Review the recorded audio - **Seek**: Navigate through the recording using waveform or progress bar - **Save**: Confirm and save the recording - **Delete**: Discard the recording and start over ### Timer Display Precision timing information: - Real-time recording duration - Centisecond accuracy (MM:SS.CC format) - Maximum duration indicator when set - Monospace font for consistent display ## Permissions The AudioRecorder automatically handles microphone permissions: - Requests permission on first use - Shows helpful error messages if denied - Provides clear instructions for enabling permissions - Gracefully handles permission changes ### iOS Permissions Add to your `Info.plist`: ```xml NSMicrophoneUsageDescription This app needs access to the microphone to record audio. ``` ### Android Permissions Add to your `AndroidManifest.xml`: ```xml ``` ## Platform Support ### iOS - Native audio recording with hardware integration - Automatic audio session management - Background recording support - Hardware control integration ### Android - Optimized audio capture - Proper lifecycle management - Background recording with proper permissions - Audio focus handling ### Web - MediaRecorder API integration - Browser compatibility fallbacks - Microphone access handling ## Accessibility The AudioRecorder follows accessibility standards: - Screen reader announcements for recording state - Clear button labels and roles - Keyboard navigation support - High contrast mode support - Proper focus management ## Performance Optimization The component is optimized for: - **Memory Efficiency**: Proper cleanup of audio resources - **Battery Life**: Efficient audio processing and minimal background activity - **Storage**: Compressed audio formats to minimize file size - **Responsiveness**: Non-blocking UI during recording operations ## Theming Automatic theme integration: - Recording button uses destructive/red theme color - Background adapts to secondary theme color - Text colors follow theme hierarchy - Supports light and dark modes - Waveform colors match theme accent ## Advanced Usage ### Custom Recording Configuration ```tsx { // Handle high-quality WAV file uploadAudioFile(uri); }} /> ``` ### Integration with Cloud Storage ```tsx function CloudRecorder() { const handleRecordingComplete = async (uri: string) => { try { // Upload to cloud storage const downloadUrl = await uploadToFirebase(uri); // Save reference in database await saveAudioRecord({ url: downloadUrl, timestamp: new Date(), duration: recordingDuration, }); Alert.alert('Success', 'Recording saved to cloud!'); } catch (error) { Alert.alert('Error', 'Failed to save recording'); } }; return ( ); } ``` ### Voice Note Integration ```tsx { // Add to voice notes collection addVoiceNote({ audioUri: uri, createdAt: new Date(), transcription: null, // Add speech-to-text later }); }} /> ``` ## Troubleshooting ### Permission Issues - Check that microphone permissions are granted - Verify Info.plist/AndroidManifest.xml configuration - Test on physical device (simulator may have limitations) ### Audio Quality Problems - Ensure device microphone is working - Check for background noise interference - Try different quality presets - Verify adequate storage space ### Recording Interruptions - Handle phone calls and other audio interruptions - Implement proper audio session management - Save partial recordings when interrupted ### Performance Issues - Reduce waveform update frequency on older devices - Use lower quality settings for better performance - Disable real-time waveform on low-end devices # Audio Waveform > A customizable audio waveform visualization component with playback progress and interactive seeking capabilities. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/audio-waveform - Markdown: https://ui.ahmedbna.com/docs/components/audio-waveform.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/audio-waveform.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/audio-waveform.json - Install: `npx bna-ui add audio-waveform` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `text`, `view` - Preview recording: https://demo.ahmedbna.com/0039-audio-waveform-demo.MP4 --- **Example:** A basic audio waveform with playback controls ```tsx // components/demo/audio-waveform/audio-waveform-demo.tsx import { AudioWaveform } from '@/components/ui/audio-waveform'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useEffect, useState } from 'react'; export function AudioWaveformDemo() { const [isPlaying, setIsPlaying] = useState(false); const [progress, setProgress] = useState(0); // Simulate audio playback progress useEffect(() => { let interval: ReturnType; if (isPlaying) { interval = setInterval(() => { setProgress((prev) => { if (prev >= 100) { setIsPlaying(false); return 0; } return prev + 2; }); }, 100); } return () => clearInterval(interval); }, [isPlaying]); const handleSeek = (position: number) => { setProgress(position); }; const togglePlayback = () => { setIsPlaying(!isPlaying); }; return ( {Math.round(progress)}% Complete ); } ``` ## Installation ### CLI ```bash npx bna-ui add audio-waveform ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install react-native-reanimated ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/audio-waveform.tsx import { useColor } from '@/hooks/useColor'; import React, { useEffect, useMemo } from 'react'; import { StyleSheet, View, ViewStyle } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { cancelAnimation, interpolate, runOnJS, useAnimatedStyle, useSharedValue, withRepeat, withSequence, withTiming, } from 'react-native-reanimated'; export interface AudioWaveformProps { data?: number[]; isPlaying?: boolean; progress?: number; onSeek?: (position: number) => void; onSeekStart?: () => void; onSeekEnd?: () => void; style?: ViewStyle; height?: number; barCount?: number; barWidth?: number; barGap?: number; activeColor?: string; inactiveColor?: string; animated?: boolean; showProgress?: boolean; interactive?: boolean; } // FIX: The Bar component now manages its own animation state. const Bar = React.memo( ({ value, height, width, isActive, showProgress, activeColor, inactiveColor, opacity, isPlaying, animated, }: { value: number; height: number; width: number; isActive: boolean; showProgress: boolean; activeColor: string; inactiveColor: string; opacity: number; isPlaying: boolean; animated: boolean; }) => { // Each Bar has its own shared value, created at the top level. This is correct. const animatedValue = useSharedValue(value); const animatedStyle = useAnimatedStyle(() => { return { height: interpolate( animatedValue.value, [0, 1], [4, height * 0.9], 'clamp' ), }; }); // This effect handles animations when the bar's data or playing state changes. useEffect(() => { if (isPlaying && animated && !showProgress) { // "Live" animation effect const randomDuration = 200 + Math.random() * 200; animatedValue.value = withRepeat( withSequence( withTiming(value * (0.9 + Math.random() * 0.2), { duration: randomDuration, }), withTiming(value * (0.9 + Math.random() * 0.2), { duration: randomDuration, }) ), -1, true ); } else { // Animate to the new static value cancelAnimation(animatedValue); animatedValue.value = withTiming(value, { duration: animated ? 250 : 0, }); } return () => { cancelAnimation(animatedValue); }; }, [value, isPlaying, animated, showProgress, animatedValue]); return ( ); } ); export function AudioWaveform({ data, isPlaying = false, progress = 0, onSeek, onSeekStart, onSeekEnd, style, height = 60, barCount = 50, barWidth = 3, barGap = 2, activeColor, inactiveColor, animated = true, showProgress = false, interactive = false, }: AudioWaveformProps) { const primaryColor = useColor('destructive'); const mutedColor = useColor('textMuted'); const finalActiveColor = activeColor || primaryColor; const finalInactiveColor = inactiveColor || mutedColor; // FIX: This now just memoizes the raw data array, not an array of hooks. const waveformData = useMemo( () => data || generateSampleWaveform(barCount), [data, barCount] ); const totalWidth = barCount * barWidth + (barCount - 1) * barGap; const getProgressLinePosition = () => { const progressRatio = Math.max(0, Math.min(100, progress)) / 100; if (progressRatio === 0) return 0; if (progressRatio === 1) return totalWidth - 1; const exactBarPosition = progressRatio * barCount; const barIndex = Math.floor(exactBarPosition); const barProgress = exactBarPosition - barIndex; let position = barIndex * (barWidth + barGap); position += barProgress * barWidth; return Math.min(position, totalWidth - 1); }; const handleSeek = (x: number) => { if (!onSeek) return; const clampedX = Math.max(0, Math.min(totalWidth, x)); const seekPercentage = (clampedX / totalWidth) * 100; onSeek(seekPercentage); }; const panGesture = Gesture.Pan() .enabled(interactive) .hitSlop({ top: 12, bottom: 12 }) .onStart((event) => { if (onSeekStart) runOnJS(onSeekStart)(); runOnJS(handleSeek)(event.x); }) .onUpdate((event) => { runOnJS(handleSeek)(event.x); }) .onEnd(() => { if (onSeekEnd) runOnJS(onSeekEnd)(); }); const handleAccessibilityAction = (event: { nativeEvent: { actionName: string }; }) => { if (!interactive || !onSeek) return; const clampedProgress = Math.max(0, Math.min(100, progress)); switch (event.nativeEvent.actionName) { case 'increment': onSeek(Math.min(100, clampedProgress + 5)); break; case 'decrement': onSeek(Math.max(0, clampedProgress - 5)); break; } }; return ( {/* FIX: We now map over the data array and pass the value to each Bar */} {waveformData.map((value, index) => { const progressRatio = progress / 100; const barProgress = (index + 0.5) / barCount; const isActive = showProgress ? barProgress <= progressRatio : false; let opacity = 1; if (showProgress && barProgress > progressRatio) { const distanceFromProgress = barProgress - progressRatio; opacity = Math.max(0.3, 1 - distanceFromProgress * 2); } return ( ); })} {showProgress && ( )} ); } // Helper function remains the same function generateSampleWaveform(barCount: number): number[] { return Array.from({ length: barCount }, (_, i) => { const wave1 = Math.sin((i / barCount) * Math.PI * 4) * 0.3; const wave2 = Math.sin((i / barCount) * Math.PI * 8) * 0.15; const wave3 = Math.sin((i / barCount) * Math.PI * 2) * 0.2; const noise = (Math.random() - 0.5) * 0.2; const base = 0.4; const peak = Math.random() < 0.1 ? Math.random() * 0.3 : 0; return Math.max( 0.1, Math.min(0.95, base + wave1 + wave2 + wave3 + noise + peak) ); }); } const styles = StyleSheet.create({ container: { justifyContent: 'center', alignItems: 'center', position: 'relative', }, waveform: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', position: 'relative', }, barContainer: { justifyContent: 'center', alignItems: 'center', height: '100%', }, bar: { borderRadius: 1.5, minHeight: 4, }, progressLine: { position: 'absolute', width: 2, borderRadius: 1, opacity: 0.9, top: '2.5%', zIndex: 10, }, }); ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { AudioWaveform } from '@/components/ui/audio-waveform'; ``` ### Basic Usage ```tsx function MyComponent() { const [isPlaying, setIsPlaying] = useState(false); const [progress, setProgress] = useState(0); return ( ); } ``` ### With Custom Data ```tsx function MyComponent() { const audioData = [0.2, 0.5, 0.8, 0.3, 0.6, 0.9, 0.4, 0.7, 0.1, 0.5]; return ( setProgress(position)} interactive={true} /> ); } ``` ### Interactive Seeking ```tsx function AudioPlayer() { const [progress, setProgress] = useState(0); const [isPlaying, setIsPlaying] = useState(false); const [isSeeking, setIsSeeking] = useState(false); const handleSeek = (position: number) => { setProgress(position); // Update your audio player position here }; return ( setIsSeeking(true)} onSeekEnd={() => setIsSeeking(false)} activeColor='#007AFF' inactiveColor='#E5E5E7' /> ); } ``` ## Examples #### Default **Example:** A basic audio waveform with playback progress ```tsx // components/demo/audio-waveform/audio-waveform-demo.tsx import { AudioWaveform } from '@/components/ui/audio-waveform'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useEffect, useState } from 'react'; export function AudioWaveformDemo() { const [isPlaying, setIsPlaying] = useState(false); const [progress, setProgress] = useState(0); // Simulate audio playback progress useEffect(() => { let interval: ReturnType; if (isPlaying) { interval = setInterval(() => { setProgress((prev) => { if (prev >= 100) { setIsPlaying(false); return 0; } return prev + 2; }); }, 100); } return () => clearInterval(interval); }, [isPlaying]); const handleSeek = (position: number) => { setProgress(position); }; const togglePlayback = () => { setIsPlaying(!isPlaying); }; return ( {Math.round(progress)}% Complete ); } ``` #### Recording Mode **Example:** An animated waveform for recording visualization ```tsx // components/demo/audio-waveform/audio-waveform-recording.tsx import { AudioWaveform } from '@/components/ui/audio-waveform'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useEffect, useState } from 'react'; export function AudioWaveformRecording() { const [isRecording, setIsRecording] = useState(false); const [recordingData, setRecordingData] = useState([]); const [duration, setDuration] = useState(0); // Simulate recording with real-time audio levels useEffect(() => { let interval: ReturnType; if (isRecording) { interval = setInterval(() => { // Generate random audio level (simulating microphone input) const newLevel = Math.max( 0.1, Math.random() * 0.9 + Math.sin(Date.now() / 200) * 0.2 ); setRecordingData((prev) => { const newData = [...prev, newLevel]; // Keep only the last 50 data points return newData.slice(-50); }); setDuration((prev) => prev + 0.1); }, 100); } return () => clearInterval(interval); }, [isRecording]); const toggleRecording = () => { if (!isRecording) { setRecordingData([]); setDuration(0); } setIsRecording(!isRecording); }; const formatDuration = (seconds: number) => { const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, '0')}`; }; return ( {formatDuration(duration)} {recordingData.length > 0 && !isRecording && ( Tap play to preview your recording )} ); } ``` #### Interactive Seeking **Example:** A waveform with touch-based seeking functionality ```tsx // components/demo/audio-waveform/audio-waveform-interactive.tsx import { AudioWaveform } from '@/components/ui/audio-waveform'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useEffect, useState } from 'react'; export function AudioWaveformInteractive() { const [isPlaying, setIsPlaying] = useState(false); const [progress, setProgress] = useState(0); const [isSeeking, setIsSeeking] = useState(false); // Sample audio data - more realistic pattern const audioData = [ 0.2, 0.4, 0.3, 0.6, 0.8, 0.5, 0.7, 0.9, 0.4, 0.3, 0.5, 0.7, 0.6, 0.8, 0.9, 0.7, 0.5, 0.3, 0.4, 0.6, 0.8, 0.9, 0.7, 0.5, 0.4, 0.6, 0.8, 0.7, 0.5, 0.3, 0.4, 0.6, 0.9, 0.8, 0.6, 0.4, 0.2, 0.3, 0.5, 0.7, ]; // Auto-play simulation useEffect(() => { let interval: ReturnType; if (isPlaying && !isSeeking) { interval = setInterval(() => { setProgress((prev) => { if (prev >= 100) { setIsPlaying(false); return 100; } return prev + 1; }); }, 100); } return () => clearInterval(interval); }, [isPlaying, isSeeking]); const handleSeek = (position: number) => { setProgress(position); }; const handleSeekStart = () => { setIsSeeking(true); }; const handleSeekEnd = () => { setIsSeeking(false); }; const togglePlayback = () => { setIsPlaying(!isPlaying); }; const formatTime = (percentage: number) => { const totalSeconds = 180; // 3 minutes total const currentSeconds = (percentage / 100) * totalSeconds; const minutes = Math.floor(currentSeconds / 60); const seconds = Math.floor(currentSeconds % 60); return `${minutes}:${seconds.toString().padStart(2, '0')}`; }; return ( {formatTime(progress)} 3:00 {isSeeking && ( Seeking... )} ); } ``` #### Custom Styling **Example:** A waveform with custom colors and dimensions ```tsx // components/demo/audio-waveform/audio-waveform-styled.tsx import { AudioWaveform } from '@/components/ui/audio-waveform'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function AudioWaveformStyled() { const [isPlaying1, setIsPlaying1] = useState(false); const [isPlaying2, setIsPlaying2] = useState(false); const [isPlaying3, setIsPlaying3] = useState(false); const [progress1, setProgress1] = useState(35); const [progress2, setProgress2] = useState(60); const [progress3, setProgress3] = useState(80); const musicData = [ 0.1, 0.3, 0.5, 0.4, 0.6, 0.8, 0.7, 0.9, 0.6, 0.4, 0.5, 0.7, 0.8, 0.9, 0.7, 0.5, 0.3, 0.4, 0.6, 0.8, 0.9, 0.7, 0.5, 0.4, 0.6, 0.8, 0.7, 0.5, 0.3, 0.4, ]; const voiceData = [ 0.2, 0.4, 0.3, 0.5, 0.4, 0.6, 0.5, 0.7, 0.4, 0.3, 0.5, 0.4, 0.6, 0.5, 0.4, 0.3, 0.4, 0.5, 0.6, 0.4, 0.3, 0.5, 0.4, 0.3, 0.4, 0.5, 0.4, 0.3, 0.2, 0.3, ]; const podcastData = [ 0.3, 0.5, 0.4, 0.6, 0.5, 0.4, 0.6, 0.7, 0.5, 0.4, 0.6, 0.5, 0.7, 0.6, 0.5, 0.4, 0.5, 0.6, 0.7, 0.5, 0.4, 0.6, 0.5, 0.4, 0.5, 0.6, 0.5, 0.4, 0.3, 0.4, ]; return ( {/* Music Style - Vibrant gradient colors */} 🎵 Music Track 2:15 / 3:45 {/* Voice Message Style - Clean and minimal */} 🎙️ Voice Message 0:45 {/* Podcast Style - Professional dark theme */} 🎧 Podcast Episode 45:30 / 58:15 ); } ``` #### Real-time Data **Example:** A waveform that updates with real-time audio data ```tsx // components/demo/audio-waveform/audio-waveform-realtime.tsx import { AudioWaveform } from '@/components/ui/audio-waveform'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useEffect, useState } from 'react'; export function AudioWaveformRealtime() { const [isActive, setIsActive] = useState(false); const [realtimeData, setRealtimeData] = useState([]); const [frequency, setFrequency] = useState(1); const [amplitude, setAmplitude] = useState(0.5); // Simulate real-time audio data with different patterns useEffect(() => { let interval: ReturnType; if (isActive) { interval = setInterval(() => { const time = Date.now() / 1000; // Generate different wave patterns based on frequency and amplitude let newLevel = 0; if (frequency === 1) { // Sine wave newLevel = Math.abs(Math.sin(time * 2) * amplitude); } else if (frequency === 2) { // Multiple frequencies combined newLevel = Math.abs( ((Math.sin(time * 3) + Math.sin(time * 1.5) + Math.sin(time * 0.8)) / 3) * amplitude ); } else { // Random with trend newLevel = Math.max( 0, Math.min(1, Math.random() * amplitude + Math.sin(time * 0.5) * 0.3) ); } setRealtimeData((prev) => { const newData = [...prev, newLevel]; // Keep only the last 60 data points for smooth animation return newData.slice(-60); }); }, 50); } return () => clearInterval(interval); }, [isActive, frequency, amplitude]); const resetData = () => { setRealtimeData([]); }; const patternButtons = [ { id: 1, label: 'Sine Wave', color: '#007AFF' }, { id: 2, label: 'Complex', color: '#34C759' }, { id: 3, label: 'Random', color: '#FF9500' }, ]; return ( Real-time Audio Visualization {realtimeData.length} data points {isActive ? 'Live' : 'Stopped'} {/* Controls */} {/* Pattern Selection */} Wave Pattern: {patternButtons.map((pattern) => ( ))} {/* Amplitude Control */} Amplitude: {Math.round(amplitude * 100)}% Simulates real-time audio input with different wave patterns ); } ``` #### Compact Size **Example:** A smaller waveform suitable for chat messages ```tsx // components/demo/audio-waveform/audio-waveform-compact.tsx import { AudioWaveform } from '@/components/ui/audio-waveform'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function AudioWaveformCompact() { const [isPlaying1, setIsPlaying1] = useState(false); const [isPlaying2, setIsPlaying2] = useState(false); const [isPlaying3, setIsPlaying3] = useState(false); const [progress1, setProgress1] = useState(20); const [progress2, setProgress2] = useState(45); const [progress3, setProgress3] = useState(70); // Compact chat message data const messageData1 = [ 0.3, 0.5, 0.4, 0.6, 0.3, 0.4, 0.5, 0.3, 0.4, 0.6, 0.5, 0.3, 0.4, 0.5, 0.3, ]; const messageData2 = [ 0.2, 0.4, 0.6, 0.5, 0.3, 0.5, 0.4, 0.6, 0.3, 0.4, 0.5, 0.4, 0.3, 0.2, 0.3, ]; const messageData3 = [ 0.4, 0.6, 0.5, 0.7, 0.4, 0.3, 0.5, 0.6, 0.4, 0.5, 0.6, 0.5, 0.4, 0.3, 0.4, ]; const MessageBubble = ({ data, isPlaying, setIsPlaying, progress, setProgress, duration, sent = false, }: { data: number[]; isPlaying: boolean; setIsPlaying: (playing: boolean) => void; progress: number; setProgress: (progress: number) => void; duration: string; sent?: boolean; }) => ( {duration} ); return ( ); } ``` ## API Reference ### AudioWaveform The main AudioWaveform component. | Prop | Type | Default | Description | | --------------- | ---------------------------- | ----------- | ----------------------------------------------------------------- | | `data` | `number[]` | - | Audio amplitude data (0-1 range). Auto-generated if not provided. | | `isPlaying` | `boolean` | `false` | Whether the audio is currently playing. | | `progress` | `number` | `0` | Current playback progress (0-100). | | `onSeek` | `(position: number) => void` | - | Callback fired when user seeks to a position. | | `onSeekStart` | `() => void` | - | Callback fired when seeking starts. | | `onSeekEnd` | `() => void` | - | Callback fired when seeking ends. | | `style` | `ViewStyle` | - | Additional styles for the container. | | `height` | `number` | `60` | Height of the waveform in pixels. | | `barCount` | `number` | `50` | Number of bars in the waveform. | | `barWidth` | `number` | `3` | Width of each bar in pixels. | | `barGap` | `number` | `2` | Gap between bars in pixels. | | `activeColor` | `string` | theme color | Color for played/active portion. | | `inactiveColor` | `string` | theme color | Color for unplayed/inactive portion. | | `animated` | `boolean` | `true` | Whether to animate the waveform during playback. | | `showProgress` | `boolean` | `false` | Whether to show progress indicator. | | `interactive` | `boolean` | `false` | Whether to enable touch-based seeking. | ## Usage Patterns ### Audio Player Integration ```tsx function AudioPlayer({ audioUrl }: { audioUrl: string }) { const [isPlaying, setIsPlaying] = useState(false); const [progress, setProgress] = useState(0); const [duration, setDuration] = useState(0); const [audioData, setAudioData] = useState([]); // Load audio data from file useEffect(() => { loadAudioWaveform(audioUrl).then(setAudioData); }, [audioUrl]); const handleSeek = (position: number) => { const newTime = (position / 100) * duration; // Seek audio to newTime setProgress(position); }; return ( ); } ``` ### Voice Message ```tsx function VoiceMessage({ duration, audioData }: VoiceMessageProps) { const [isPlaying, setIsPlaying] = useState(false); const [progress, setProgress] = useState(0); return ( setIsPlaying(!isPlaying)}> {formatDuration(duration)} ); } ``` ### Recording Visualization ```tsx function RecordingView() { const [isRecording, setIsRecording] = useState(false); const [recordingData, setRecordingData] = useState([]); // Update waveform with real-time audio levels useEffect(() => { if (isRecording) { const interval = setInterval(() => { const newLevel = getCurrentAudioLevel(); // Your audio level function setRecordingData((prev) => [...prev.slice(-49), newLevel]); }, 100); return () => clearInterval(interval); } }, [isRecording]); return ( ); } ``` ## Accessibility The AudioWaveform component follows accessibility best practices: - Proper touch target sizes for interactive elements - Screen reader support for progress information - Respects system accessibility settings - Keyboard navigation support where applicable ## Performance The component is optimized for performance: - Uses `react-native-reanimated` for smooth native animations - Efficient rendering with minimal re-renders - Configurable bar count to balance detail vs performance - Native driver animations where possible ## Theming The AudioWaveform automatically adapts to your app's theme: - Uses theme colors by default for active/inactive states - Supports both light and dark modes - Customizable colors through props - Respects theme opacity values ## Data Format Audio data should be provided as an array of numbers between 0 and 1: ```tsx const audioData = [ 0.1, // Very quiet 0.3, // Quiet 0.5, // Medium 0.8, // Loud 1.0, // Maximum ]; ``` If no data is provided, the component generates realistic sample data automatically. # Avatar > An image element with a fallback for representing the user. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/avatar - Markdown: https://ui.ahmedbna.com/docs/components/avatar.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/avatar.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/avatar.json - Install: `npx bna-ui add avatar` - npm dependencies: `expo-image` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `text`, `view`, `image` - Preview recording: https://demo.ahmedbna.com/0045-avatar-demo.PNG --- **Example:** A basic avatar with image and fallback text ```tsx // components/demo/avatar/avatar-demo.tsx import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import React from 'react'; export function AvatarDemo() { return ( AB ); } ``` ## Installation ### CLI ```bash npx bna-ui add avatar ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install expo-image ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/avatar.tsx import { Image } from '@/components/ui/image'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { FONT_SIZE } from '@/theme/globals'; import { ImageProps, ImageSource } from 'expo-image'; import { createContext, Dispatch, memo, SetStateAction, useContext, useState, } from 'react'; import { TextStyle, ViewStyle } from 'react-native'; type AvatarImageStatus = 'loading' | 'loaded' | 'error'; interface AvatarContextValue { status: AvatarImageStatus; setStatus: Dispatch>; } // Connects AvatarImage's load state to AvatarFallback so the fallback shows // automatically on error (or while there's no image at all) and hides once // the image has actually loaded, instead of being rendered unconditionally // by whatever the consumer puts in JSX. const AvatarContext = createContext(null); const useAvatarContext = () => { const context = useContext(AvatarContext); if (!context) { throw new Error('Avatar subcomponents must be used within an Avatar'); } return context; }; interface AvatarProps { children: React.ReactNode; size?: number; style?: ViewStyle; } export const Avatar = memo(function Avatar({ children, size = 40, style, }: AvatarProps) { const [status, setStatus] = useState('loading'); return ( {children} ); }); interface AvatarImageProps { source: ImageSource; style?: ImageProps['style']; } export const AvatarImage = memo(function AvatarImage({ source, style, }: AvatarImageProps) { const { setStatus } = useAvatarContext(); return ( setStatus('loading')} onError={() => setStatus('error')} onLoadEnd={() => setStatus((prev) => (prev === 'error' ? 'error' : 'loaded')) } /> ); }); interface AvatarFallbackProps { children: React.ReactNode; style?: ViewStyle; textStyle?: TextStyle; } export const AvatarFallback = memo(function AvatarFallback({ children, style, textStyle, }: AvatarFallbackProps) { const { status } = useAvatarContext(); const mutedColor = useColor('muted'); const mutedForegroundColor = useColor('mutedForeground'); if (status === 'loaded') return null; return ( {children} ); }); ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; ``` ```tsx AB ``` ## Examples #### Default **Example:** A basic avatar with image and fallback text ```tsx // components/demo/avatar/avatar-demo.tsx import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import React from 'react'; export function AvatarDemo() { return ( AB ); } ``` #### Sizes **Example:** Avatars in different sizes ```tsx // components/demo/avatar/avatar-sizes.tsx import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { View } from '@/components/ui/view'; import React from 'react'; export function AvatarSizes() { return ( AB AB AB AB AB ); } ``` #### Fallback Only **Example:** Avatars with fallback text when no image is provided ```tsx // components/demo/avatar/avatar-fallback.tsx import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { View } from '@/components/ui/view'; import React from 'react'; export function AvatarFallbackDemo() { return ( JD AB MK SL ); } ``` #### Custom Styling **Example:** Avatars with custom styling and colors ```tsx // components/demo/avatar/avatar-styled.tsx import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { View } from '@/components/ui/view'; import React from 'react'; export function AvatarStyled() { return ( AB BNA EX ); } ``` #### Group **Example:** Multiple avatars arranged in a group layout ```tsx // components/demo/avatar/avatar-group.tsx import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { View } from '@/components/ui/view'; import React from 'react'; export function AvatarGroup() { return ( AB AB EX +5 ); } ``` #### With Status **Example:** Avatars with online/offline status indicators ```tsx // components/demo/avatar/avatar-status.tsx import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { View } from '@/components/ui/view'; import React from 'react'; export function AvatarStatus() { return ( AB BNA EX ); } ``` #### Bordered **Example:** Avatars with custom borders and shadows ```tsx // components/demo/avatar/avatar-bordered.tsx import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { View } from '@/components/ui/view'; import React from 'react'; export function AvatarBordered() { return ( AB BNA EX ); } ``` ## API Reference ### Avatar The container component that wraps the avatar image and fallback. | Prop | Type | Default | Description | | ---------- | ----------- | ------- | --------------------------------------------------- | | `children` | `ReactNode` | - | The avatar image and fallback components. | | `size` | `number` | `40` | The size of the avatar in pixels. | | `style` | `ViewStyle` | - | Additional styles to apply to the avatar container. | ### AvatarImage The image component that displays the user's avatar. | Prop | Type | Description | | -------- | ------------------ | ---------------------------------------- | | `source` | `ImageSource` | The image source for the avatar. | | `style` | `ImageProps.style` | Additional styles to apply to the image. | ### AvatarFallback The fallback component that displays when the image fails to load or is not provided. | Prop | Type | Description | | ----------- | ----------- | ----------------------------------------------------- | | `children` | `ReactNode` | The fallback content (usually initials or text). | | `style` | `ViewStyle` | Additional styles to apply to the fallback container. | | `textStyle` | `TextStyle` | Additional styles to apply to the fallback text. | ## Accessibility The Avatar component is built with accessibility in mind: - Uses semantic structure for screen readers - Fallback text provides alternative content when images fail to load - Proper contrast ratios for text fallbacks - Supports dynamic text sizing # AvoidKeyboard > A component that automatically adjusts its height to avoid keyboard overlap with smooth animations and cross-platform support. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/avoid-keyboard - Markdown: https://ui.ahmedbna.com/docs/components/avoid-keyboard.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/avoid-keyboard.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/avoid-keyboard.json - Install: `npx bna-ui add avoid-keyboard` - npm dependencies: `react-native-reanimated`, `react-native-worklets` - Registry dependencies: `useKeyboardHeight` - Preview recording: https://demo.ahmedbna.com/0052-avoid-keyboard-demo.MP4 --- **Example:** Basic keyboard avoidance with animated height adjustment ```tsx // components/demo/avoid-keyboard/avoid-keyboard-demo.tsx import { AvoidKeyboard } from '@/components/ui/avoid-keyboard'; import { Input } from '@/components/ui/input'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useKeyboardHeight } from '@/hooks/useKeyboardHeight'; import React from 'react'; export function AvoidKeyboardDemo() { const { keyboardHeight, isKeyboardVisible, keyboardAnimationDuration } = useKeyboardHeight(); return ( Basic Keyboard Avoidance Tap the input below to see the keyboard avoidance in action. The content will smoothly move up to keep the input visible. {/* Spacer to push input toward bottom */} Keyboard Height: {keyboardHeight} Keyboard Visible: {isKeyboardVisible ? 'Yes' : 'No'} Animation Duration: {keyboardAnimationDuration}ms {/* This will create space to avoid the keyboard */} ); } ``` ## Installation ### CLI ```bash npx bna-ui add avoid-keyboard ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install react-native-reanimated ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/avoid-keyboard.tsx import { useKeyboardHeight } from '@/hooks/useKeyboardHeight'; import { useEffect } from 'react'; import Animated, { Easing, useAnimatedStyle, useReducedMotion, useSharedValue, withTiming, } from 'react-native-reanimated'; type Props = { offset?: number; duration?: number }; export const AvoidKeyboard = ({ offset = 0, duration = 0 }: Props) => { const { keyboardHeight, isKeyboardVisible, keyboardAnimationDuration } = useKeyboardHeight(); const reduceMotion = useReducedMotion(); // Shared value for the keyboard padding animation const keyboardValue = useSharedValue(0); // Update the shared value when keyboard height changes useEffect(() => { // Only add offset when keyboard is visible const targetHeight = isKeyboardVisible ? keyboardHeight + offset : 0; if (reduceMotion) { keyboardValue.value = targetHeight; return; } // Use different easing for show vs hide to match native behavior const easing = isKeyboardVisible ? Easing.out(Easing.quad) // Smooth out for keyboard show : Easing.in(Easing.quad); // Smooth in for keyboard hide keyboardValue.value = withTiming(targetHeight, { duration: keyboardAnimationDuration + duration, easing, }); }, [ keyboardHeight, keyboardAnimationDuration, isKeyboardVisible, offset, duration, reduceMotion, ]); // Animated style const keyboardMargin = useAnimatedStyle(() => { return { height: keyboardValue.value, }; }); return ; }; ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { AvoidKeyboard } from '@/components/ui/avoid-keyboard'; ``` ```tsx {/* Your content */} {/* This will push content up when keyboard appears */} ``` ## Examples #### Basic Usage **Example:** Simple keyboard avoidance with default settings ```tsx // components/demo/avoid-keyboard/avoid-keyboard-demo.tsx import { AvoidKeyboard } from '@/components/ui/avoid-keyboard'; import { Input } from '@/components/ui/input'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useKeyboardHeight } from '@/hooks/useKeyboardHeight'; import React from 'react'; export function AvoidKeyboardDemo() { const { keyboardHeight, isKeyboardVisible, keyboardAnimationDuration } = useKeyboardHeight(); return ( Basic Keyboard Avoidance Tap the input below to see the keyboard avoidance in action. The content will smoothly move up to keep the input visible. {/* Spacer to push input toward bottom */} Keyboard Height: {keyboardHeight} Keyboard Visible: {isKeyboardVisible ? 'Yes' : 'No'} Animation Duration: {keyboardAnimationDuration}ms {/* This will create space to avoid the keyboard */} ); } ``` #### With Offset **Example:** Add extra spacing above the keyboard ```tsx // components/demo/avoid-keyboard/avoid-keyboard-offset.tsx import { AvoidKeyboard } from '@/components/ui/avoid-keyboard'; import { Input } from '@/components/ui/input'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function AvoidKeyboardOffset() { return ( With Extra Offset This example adds 40px of extra spacing above the keyboard for better visual separation. {/* Spacer to push input toward bottom */} {/* Add 40px extra spacing above keyboard */} ); } ``` #### Custom Duration **Example:** Customize animation timing for different effects ```tsx // components/demo/avoid-keyboard/avoid-keyboard-duration.tsx import { AvoidKeyboard } from '@/components/ui/avoid-keyboard'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function AvoidKeyboardDuration() { const [duration, setDuration] = useState(0); const durations = [ { label: 'Default', value: 0 }, { label: 'Fast (100ms)', value: 100 }, { label: 'Slow (500ms)', value: 500 }, { label: 'Very Slow (1000ms)', value: 1000 }, ]; return ( Custom Animation Duration Choose different animation speeds to see how it affects the keyboard avoidance: {durations.map((item) => ( ))} Current duration: {duration}ms extra {/* Spacer to push input toward bottom */} {/* Use custom duration */} ); } ``` #### Chat Interface **Example:** Real-world chat interface example ```tsx // components/demo/avoid-keyboard/avoid-keyboard-chat.tsx import { AvoidKeyboard } from '@/components/ui/avoid-keyboard'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { Send, SendHorizonal } from 'lucide-react-native'; import React, { useState } from 'react'; import { FlatList, Pressable } from 'react-native'; interface Message { id: string; text: string; isUser: boolean; timestamp: Date; } export function AvoidKeyboardChat() { const card = useColor('card'); const blue = useColor('blue'); const [messages, setMessages] = useState([ { id: '1', text: 'Hey! How are you doing?', isUser: false, timestamp: new Date(Date.now() - 300000), }, { id: '2', text: "Hi there! I'm doing great, thanks for asking!", isUser: true, timestamp: new Date(Date.now() - 240000), }, { id: '3', text: "That's wonderful to hear! Any exciting plans for today?", isUser: false, timestamp: new Date(Date.now() - 180000), }, { id: '4', text: "Actually yes! I'm working on some new React Native components.", isUser: true, timestamp: new Date(Date.now() - 120000), }, ]); const [inputText, setInputText] = useState(''); const sendMessage = () => { if (inputText.trim()) { const newMessage: Message = { id: Date.now().toString(), text: inputText.trim(), isUser: true, timestamp: new Date(), }; setMessages((prev) => [...prev, newMessage]); setInputText(''); // Simulate response after a delay setTimeout(() => { const responses = [ 'That sounds interesting!', 'Tell me more about that.', "Cool! How's it going?", 'Nice work!', ]; const response: Message = { id: (Date.now() + 1).toString(), text: responses[Math.floor(Math.random() * responses.length)], isUser: false, timestamp: new Date(), }; setMessages((prev) => [...prev, response]); }, 1000); } }; const renderMessage = ({ item }: { item: Message }) => ( {item.text} {item.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', })} ); return ( {/* Header */} Chat Demo Real-time chat with keyboard avoidance {/* Messages */} item.id} style={{ flex: 1 }} contentContainerStyle={{ padding: 16 }} showsVerticalScrollIndicator={false} /> {/* Input Area */} {/* Keyboard avoidance with extra space for better UX */} ); } ``` #### Form Example **Example:** Form with multiple inputs and keyboard avoidance ```tsx // components/demo/avoid-keyboard/avoid-keyboard-form.tsx import { AvoidKeyboard } from '@/components/ui/avoid-keyboard'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { Mail, Lock, User, Phone, MessageSquare } from 'lucide-react-native'; import React, { useState } from 'react'; import { ScrollView } from 'react-native'; export function AvoidKeyboardForm() { const [formData, setFormData] = useState({ first: '', last: '', email: '', phone: '', password: '', confrim: '', message: '', }); const [errors, setErrors] = useState>({}); const validateForm = () => { const newErrors: Record = {}; if (!formData.first.trim()) { newErrors.first = 'Name is required'; } if (!formData.email.trim()) { newErrors.email = 'Email is required'; } else if (!/\S+@\S+\.\S+/.test(formData.email)) { newErrors.email = 'Please enter a valid email'; } if (!formData.password.trim()) { newErrors.password = 'Password is required'; } else if (formData.password.length < 6) { newErrors.password = 'Password must be at least 6 characters'; } setErrors(newErrors); return Object.keys(newErrors).length === 0; }; const handleSubmit = () => { if (validateForm()) { // Form is valid console.log('Form submitted:', formData); // Reset form setFormData({ first: '', last: '', email: '', phone: '', password: '', confrim: '', message: '', }); setErrors({}); } }; const updateField = (field: keyof typeof formData, value: string) => { setFormData((prev) => ({ ...prev, [field]: value })); // Clear error when user starts typing if (errors[field]) { setErrors((prev) => ({ ...prev, [field]: '' })); } }; return ( {/* Header */} Registration Form Fill out the form below. Notice how the keyboard avoidance keeps inputs visible. {/* Form Content */} updateField('first', value)} error={errors.first} /> updateField('last', value)} error={errors.last} /> updateField('email', value)} error={errors.email} keyboardType='email-address' autoCapitalize='none' /> updateField('email', value)} error={errors.email} keyboardType='email-address' autoCapitalize='none' /> updateField('phone', value)} error={errors.phone} keyboardType='phone-pad' /> updateField('password', value)} error={errors.password} secureTextEntry /> updateField('confrim', value)} error={errors.confrim} secureTextEntry /> By creating an account, you agree to our Terms of Service and Privacy Policy. {/* Keyboard avoidance for the form */} ); } ``` #### Playground **Example:** Playground to test different configurations ```tsx // components/demo/avoid-keyboard/avoid-keyboard-playground.tsx import { AvoidKeyboard } from '@/components/ui/avoid-keyboard'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useKeyboardHeight } from '@/hooks/useKeyboardHeight'; import { useColor } from '@/hooks/useColor'; import { Settings, Keyboard, Smartphone } from 'lucide-react-native'; import React, { useState } from 'react'; import { ScrollView, Switch } from 'react-native'; export function AvoidKeyboardPlayground() { const [message, setMessage] = useState(''); const [offset, setOffset] = useState(20); const [duration, setDuration] = useState(0); const [showStats, setShowStats] = useState(false); const card = useColor('card'); // Get keyboard stats for debugging const { keyboardHeight, isKeyboardVisible, keyboardAnimationDuration } = useKeyboardHeight(); const presetOffsets = [0, 10, 20, 40, 60]; const presetDurations = [0, 100, 250, 500]; return ( {/* Controls */} {/* Header */} AvoidKeyboard Playground Test different configurations and see real-time keyboard stats {/* Debug Stats Toggle */} Show Keyboard Stats {/* Keyboard Stats */} {showStats && ( Keyboard Status Visible: {isKeyboardVisible ? '✅ Yes' : '❌ No'} Height: {keyboardHeight}px Animation Duration: {keyboardAnimationDuration}ms )} {/* Offset Controls */} Offset Configuration Extra space above keyboard: {offset}px {presetOffsets.map((value) => ( ))} {/* Duration Controls */} Animation Duration Extra animation time: {duration}ms {presetDurations.map((value) => ( ))} {/* Usage Examples */} Code Example {` 0 ? ` offset={${offset}}` : ''}${ duration > 0 ? ` duration={${duration}}` : '' } />`} {/* Spacer to push test input to bottom */} {/* Test Input Area */} Test Input {/* The actual AvoidKeyboard component */} ); } ``` ## API Reference ### AvoidKeyboard Automatically adjusts height to avoid keyboard overlap with smooth animations. | Prop | Type | Default | Description | | ---------- | -------- | ------- | --------------------------------------------- | | `offset` | `number` | `0` | Additional spacing above the keyboard (in px) | | `duration` | `number` | `0` | Extra animation duration (in ms) | ## Features - **Cross-platform support**: Works on both iOS and Android - **Smooth animations**: Uses Reanimated for 60fps animations - **Smart timing**: Matches native keyboard animation duration - **Flexible offset**: Add extra spacing as needed - **Automatic cleanup**: Handles component unmounting gracefully - **Screen rotation**: Adapts to orientation changes ## How It Works The component uses the `useKeyboardHeight` hook to: 1. **Listen to keyboard events**: Tracks show/hide events on both platforms 2. **Measure keyboard height**: Gets accurate height measurements 3. **Animate smoothly**: Uses platform-appropriate easing curves 4. **Handle edge cases**: Manages screen rotation and invalid values ## Platform Differences ### iOS - Uses `keyboardWillShow/Hide` for smoother animations - Provides animation duration in keyboard events - Better landscape keyboard height detection ### Android - Uses `keyboardDidShow/Hide` events - Falls back to default animation duration - Handles software keyboard variations ## Best Practices ### Placement ```tsx // ✅ Good - Place at bottom of your layout {/* Your content */} // ❌ Avoid - Don't place in middle of content {/* More content */} ``` ### With ScrollView ```tsx // ✅ Recommended pattern {/* Your content */} ``` ### Multiple Inputs ```tsx // ✅ Single AvoidKeyboard for multiple inputs ``` ### Performance - Use a single `AvoidKeyboard` per screen - Avoid nesting multiple instances - Consider using `offset` instead of margin for spacing ## Troubleshooting ### Common Issues **Keyboard not detected:** - Ensure React Native Reanimated is properly installed - Check if `useKeyboardHeight` hook is working correctly **Animation feels choppy:** - Verify Reanimated 2+ is installed - Check if Hermes is enabled (recommended) **Wrong height on Android:** - Some Android keyboards report incorrect heights - Consider using a small `offset` as buffer **Landscape mode issues:** - Component handles basic landscape detection - For complex cases, listen to orientation changes ### Debug Mode ```tsx // Add this for debugging const { keyboardHeight, isKeyboardVisible } = useKeyboardHeight(); console.log('Keyboard:', { keyboardHeight, isKeyboardVisible }); ``` ## Accessibility The AvoidKeyboard component enhances accessibility by: - **Preventing content hiding**: Ensures form inputs remain visible - **Maintaining focus**: Keeps focused elements in view - **Supporting assistive tech**: Works with screen readers and voice control - **Respecting user settings**: Honors system animation preferences ## Integration with Other Libraries ### React Navigation ```tsx // Works seamlessly with React Navigation function ChatScreen() { return ( ); } ``` ### KeyboardAvoidingView Alternative ```tsx // Replace KeyboardAvoidingView with AvoidKeyboard // ❌ Old way // ✅ New way ``` This component provides a more reliable and smoother alternative to React Native's built-in `KeyboardAvoidingView` with better cross-platform consistency. # Badge > A small status descriptor for UI elements. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/badge - Markdown: https://ui.ahmedbna.com/docs/components/badge.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/badge.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/badge.json - Install: `npx bna-ui add badge` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `text`, `view` - Preview recording: https://demo.ahmedbna.com/0058-badge-demo.PNG --- **Example:** A basic badge showing different variants ```tsx // components/demo/badge/badge-demo.tsx import { Badge } from '@/components/ui/badge'; import { View } from '@/components/ui/view'; import React from 'react'; export function BadgeDemo() { return ( Default Secondary Destructive Outline Success ); } ``` ## Installation ### CLI ```bash npx bna-ui add badge ``` ### Manual **1.** Copy and paste the following code into your project. ```tsx // components/ui/badge.tsx import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { CORNERS } from '@/theme/globals'; import { TextStyle, ViewStyle } from 'react-native'; type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline' | 'success'; interface BadgeProps { children: React.ReactNode; variant?: BadgeVariant; style?: ViewStyle; textStyle?: TextStyle; accessibilityLabel?: string; } export function Badge({ children, variant = 'default', style, textStyle, accessibilityLabel, }: BadgeProps) { const primaryColor = useColor('primary'); const primaryForegroundColor = useColor('primaryForeground'); const secondaryColor = useColor('secondary'); const secondaryForegroundColor = useColor('secondaryForeground'); const destructiveColor = useColor('destructive'); const destructiveForegroundColor = useColor('destructiveForeground'); const borderColor = useColor('border'); const successColor = useColor('success'); const successForegroundColor = useColor('successForeground'); const getBadgeStyle = (): ViewStyle => { const baseStyle: ViewStyle = { alignItems: 'center', justifyContent: 'center', paddingVertical: 6, paddingHorizontal: 12, borderRadius: CORNERS, }; switch (variant) { case 'secondary': return { ...baseStyle, backgroundColor: secondaryColor }; case 'destructive': return { ...baseStyle, backgroundColor: destructiveColor }; case 'success': return { ...baseStyle, backgroundColor: successColor }; case 'outline': return { ...baseStyle, backgroundColor: 'transparent', borderWidth: 1, borderColor, }; default: return { ...baseStyle, backgroundColor: primaryColor }; } }; const getTextStyle = (): TextStyle => { const baseTextStyle: TextStyle = { fontSize: 15, fontWeight: '500', textAlign: 'center', }; switch (variant) { case 'secondary': return { ...baseTextStyle, color: secondaryForegroundColor }; case 'destructive': return { ...baseTextStyle, color: destructiveForegroundColor }; case 'success': return { ...baseTextStyle, color: successForegroundColor }; case 'outline': return { ...baseTextStyle, color: primaryColor }; default: return { ...baseTextStyle, color: primaryForegroundColor }; } }; const defaultAccessibilityLabel = typeof children === 'string' || typeof children === 'number' ? String(children) : undefined; return ( {children} ); } ``` **2.** Update the import paths to match your project setup. ## Usage ```tsx import { Badge } from '@/components/ui/badge'; ``` ```tsx Default Secondary Destructive Outline Success ``` ## Examples #### Default **Example:** Basic badges showing all available variants ```tsx // components/demo/badge/badge-demo.tsx import { Badge } from '@/components/ui/badge'; import { View } from '@/components/ui/view'; import React from 'react'; export function BadgeDemo() { return ( Default Secondary Destructive Outline Success ); } ``` #### With Icons **Example:** Badges with icons and custom content ```tsx // components/demo/badge/badge-icons.tsx import { Badge } from '@/components/ui/badge'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function BadgeIcons() { return ( ★ Featured Verified Alert 🔔 Notification ); } ``` #### Notification Badges **Example:** Small notification badges for counters and status ```tsx // components/demo/badge/badge-notifications.tsx import { Badge } from '@/components/ui/badge'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function BadgeNotifications() { return ( {/* Small notification counters */} Messages 3 Notifications 12 {/* Dot indicators */} Online Away Offline ); } ``` #### Custom Styling **Example:** Badges with custom colors and styling ```tsx // components/demo/badge/badge-styled.tsx import { Badge } from '@/components/ui/badge'; import { View } from '@/components/ui/view'; import React from 'react'; export function BadgeStyled() { return ( {/* Custom colors */} Purple Cyan Orange {/* Gradient-like effect with shadow */} Pink {/* Bordered with custom style */} Green ); } ``` #### Interactive Badges **Example:** Badges that can be pressed or dismissed ```tsx // components/demo/badge/badge-interactive.tsx import { Badge } from '@/components/ui/badge'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; import { TouchableOpacity } from 'react-native'; export function BadgeInteractive() { const [tags, setTags] = useState(['React', 'TypeScript', 'Expo', 'Mobile']); const [selectedCategory, setSelectedCategory] = useState('All'); const categories = ['All', 'Work', 'Personal', 'Important']; const removeTag = (tagToRemove: string) => { setTags(tags.filter((tag) => tag !== tagToRemove)); }; return ( {/* Dismissible tags */} Tags (tap to remove): {tags.map((tag) => ( removeTag(tag)}> {tag} × ))} {/* Selectable categories */} Categories: {categories.map((category) => ( setSelectedCategory(category)} > {category} ))} {/* Toggle badges */} Filter Options: Active Completed Archived ); } ``` #### Sizes **Example:** Badges in different sizes ```tsx // components/demo/badge/badge-sizes.tsx import { Badge } from '@/components/ui/badge'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function BadgeSizes() { return ( {/* Extra Small */} Extra Small: XS New {/* Small */} Small: Small Beta {/* Default */} Default: Default Outline {/* Large */} Large: Large Important {/* Extra Large */} Extra Large: XL Badge ); } ``` #### Status Indicators **Example:** Badges used as status indicators ```tsx // components/demo/badge/badge-status.tsx import { Badge } from '@/components/ui/badge'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function BadgeStatus() { const users = [ { name: 'John Doe', status: 'online' }, { name: 'Jane Smith', status: 'away' }, { name: 'Bob Johnson', status: 'offline' }, { name: 'Alice Brown', status: 'busy' }, ]; const orders = [ { id: '#1234', status: 'pending' }, { id: '#1235', status: 'processing' }, { id: '#1236', status: 'shipped' }, { id: '#1237', status: 'delivered' }, { id: '#1238', status: 'cancelled' }, ]; const getStatusBadge = (status: string) => { switch (status) { case 'online': return Online; case 'away': return ( Away ); case 'busy': return Busy; case 'offline': return Offline; case 'pending': return ( Pending ); case 'processing': return ( Processing ); case 'shipped': return ( Shipped ); case 'delivered': return Delivered; case 'cancelled': return Cancelled; default: return Unknown; } }; return ( {/* User Status */} User Status {users.map((user, index) => ( {user.name} {getStatusBadge(user.status)} ))} {/* Order Status */} Order Status {orders.map((order, index) => ( Order {order.id} {getStatusBadge(order.status)} ))} {/* Priority Levels */} Priority Levels High Priority Medium Priority Low Priority ); } ``` ## API Reference ### Badge A versatile badge component for displaying status, categories, or notifications. | Prop | Type | Default | Description | | -------------------- | --------------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------- | | `children` | `ReactNode` | - | The content to display inside the badge. | | `variant` | `'default' \| 'secondary' \| 'destructive' \| 'outline' \| 'success'` | `'default'` | The visual style variant of the badge. | | `style` | `ViewStyle` | - | Additional styles to apply to the badge. | | `textStyle` | `TextStyle` | - | Additional styles to apply to the badge text. | | `accessibilityLabel` | `string` | - | Accessibility label for screen readers. Defaults to the string/number `children` when omitted. | ## Accessibility The Badge component is built with accessibility in mind: - Uses semantic structure for screen readers - Proper contrast ratios for all variants - Supports dynamic text sizing - Clear visual distinction between different states # BottomSheet > A modal sheet component that slides up from the bottom with gesture support and snap points. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/bottom-sheet - Markdown: https://ui.ahmedbna.com/docs/components/bottom-sheet.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/bottom-sheet.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/bottom-sheet.json - Install: `npx bna-ui add bottom-sheet` - npm dependencies: `react-native-gesture-handler`, `react-native-reanimated`, `react-native-safe-area-context`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `useKeyboardHeight`, `globals`, `text`, `view` - Preview recording: https://demo.ahmedbna.com/0065-bottom-sheet-demo.MP4 --- **Example:** A basic bottom sheet with gesture support and snap points ```tsx // components/demo/bottom-sheet/bottom-sheet-demo.tsx import { BottomSheet, useBottomSheet } from '@/components/ui/bottom-sheet'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function BottomSheetDemo() { const { isVisible, open, close } = useBottomSheet(); return ( Welcome to Bottom Sheet This is a basic bottom sheet that supports gesture interactions. You can drag it up and down to different snap points, or swipe down quickly to dismiss it. ); } ``` ## Installation ### CLI ```bash npx bna-ui add bottom-sheet ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install react-native-gesture-handler react-native-reanimated ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/bottom-sheet.tsx import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useKeyboardHeight } from '@/hooks/useKeyboardHeight'; // Make sure this path is correct import { useColor } from '@/hooks/useColor'; import { BORDER_RADIUS } from '@/theme/globals'; import React, { useEffect } from 'react'; import { Modal, ScrollView, TouchableWithoutFeedback, useWindowDimensions, ViewStyle, } from 'react-native'; import { Gesture, GestureDetector, GestureHandlerRootView, } from 'react-native-gesture-handler'; import Animated, { runOnJS, useAnimatedStyle, useSharedValue, withSpring, withTiming, } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; type BottomSheetContentProps = { children: React.ReactNode; title?: string; style?: ViewStyle; rBottomSheetStyle: any; cardColor: string; mutedColor: string; screenHeight: number; onHandlePress?: () => void; }; // Component for the bottom sheet content // It now includes a ScrollView by default for better form handling. const BottomSheetContent = ({ children, title, style, rBottomSheetStyle, cardColor, mutedColor, screenHeight, onHandlePress, }: BottomSheetContentProps) => { const insets = useSafeAreaInsets(); return ( {/* Handle */} {/* Title */} {title && ( {title} )} {/* Content now wrapped in a ScrollView */} {children} ); }; type BottomSheetProps = { isVisible: boolean; onClose: () => void; children: React.ReactNode; snapPoints?: number[]; enableBackdropDismiss?: boolean; title?: string; style?: ViewStyle; disablePanGesture?: boolean; }; export function BottomSheet({ isVisible, onClose, children, snapPoints = [0.3, 0.6, 0.9], enableBackdropDismiss = true, title, style, disablePanGesture = false, }: BottomSheetProps) { const cardColor = useColor('card'); const mutedColor = useColor('muted'); const { keyboardHeight, isKeyboardVisible } = useKeyboardHeight(); const { height: screenHeight } = useWindowDimensions(); const maxTranslateY = -screenHeight + 50; const translateY = useSharedValue(0); const context = useSharedValue({ y: 0 }); const opacity = useSharedValue(0); const currentSnapIndex = useSharedValue(0); // Shared value to hold keyboard height for use in worklets const keyboardHeightSV = useSharedValue(0); const snapPointsHeights = snapPoints.map((point) => -screenHeight * point); const defaultHeight = snapPointsHeights[0]; const [modalVisible, setModalVisible] = React.useState(false); // Effect to handle opening and closing the bottom sheet useEffect(() => { if (isVisible) { setModalVisible(true); translateY.value = withSpring(defaultHeight, { damping: 50, stiffness: 400, }); opacity.value = withTiming(1, { duration: 300 }); currentSnapIndex.value = 0; } else { translateY.value = withSpring(0, { damping: 50, stiffness: 400 }); opacity.value = withTiming(0, { duration: 300 }, (finished) => { if (finished) { runOnJS(setModalVisible)(false); } }); } }, [isVisible, defaultHeight]); // Function to animate the sheet to a specific destination const scrollTo = (destination: number) => { 'worklet'; translateY.value = withSpring(destination, { damping: 50, stiffness: 400 }); }; // --- START: NEW KEYBOARD HANDLING LOGIC --- useEffect(() => { // Update the shared value whenever keyboardHeight changes keyboardHeightSV.value = keyboardHeight; // Only adjust position if the sheet is currently visible if (isVisible) { const currentSnapHeight = snapPointsHeights[currentSnapIndex.value]; let destination: number; if (isKeyboardVisible) { // Keyboard is open, move sheet up by keyboard height destination = currentSnapHeight - keyboardHeight; } else { // Keyboard is closed, return to original snap point destination = currentSnapHeight; } scrollTo(destination); } }, [keyboardHeight, isKeyboardVisible, isVisible]); // --- END: NEW KEYBOARD HANDLING LOGIC --- const findClosestSnapPoint = (currentY: number) => { 'worklet'; // Adjust the currentY by the keyboard height to find the original snap point const adjustedY = currentY + keyboardHeightSV.value; let closest = snapPointsHeights[0]; let minDistance = Math.abs(adjustedY - closest); let closestIndex = 0; for (let i = 0; i < snapPointsHeights.length; i++) { const snapPoint = snapPointsHeights[i]; const distance = Math.abs(adjustedY - snapPoint); if (distance < minDistance) { minDistance = distance; closest = snapPoint; closestIndex = i; } } currentSnapIndex.value = closestIndex; return closest; }; const handlePress = () => { const nextIndex = (currentSnapIndex.value + 1) % snapPointsHeights.length; currentSnapIndex.value = nextIndex; const destination = snapPointsHeights[nextIndex] - keyboardHeightSV.value; scrollTo(destination); }; const animateClose = () => { 'worklet'; translateY.value = withSpring(0, { damping: 50, stiffness: 400 }); opacity.value = withTiming(0, { duration: 300 }, (finished) => { if (finished) { runOnJS(onClose)(); } }); }; const gesture = Gesture.Pan() .onStart(() => { context.value = { y: translateY.value }; }) .onUpdate((event) => { const newY = context.value.y + event.translationY; if (newY <= 0 && newY >= maxTranslateY) { translateY.value = newY; } }) .onEnd((event) => { const currentY = translateY.value; const velocity = event.velocityY; if (velocity > 500 && currentY > -screenHeight * 0.2) { animateClose(); return; } // Find the closest original snap point const closestSnapPoint = findClosestSnapPoint(currentY); // Calculate the final destination, accounting for the keyboard height const finalDestination = closestSnapPoint - keyboardHeightSV.value; scrollTo(finalDestination); }); const rBottomSheetStyle = useAnimatedStyle(() => { return { transform: [{ translateY: translateY.value }], }; }); const rBackdropStyle = useAnimatedStyle(() => { return { opacity: opacity.value, }; }); const handleBackdropPress = () => { if (enableBackdropDismiss) { animateClose(); } }; return ( {disablePanGesture ? ( runOnJS(handlePress)()} /> ) : ( runOnJS(handlePress)()} /> )} ); } // Hook for managing bottom sheet state export function useBottomSheet() { const [isVisible, setIsVisible] = React.useState(false); const open = React.useCallback(() => { setIsVisible(true); }, []); const close = React.useCallback(() => { setIsVisible(false); }, []); const toggle = React.useCallback(() => { setIsVisible((prev) => !prev); }, []); return { isVisible, open, close, toggle, }; } ``` **3.** Update the import paths to match your project setup. **4.** Make sure to wrap your app with GestureHandlerRootView in your root layout. ```tsx import { GestureHandlerRootView } from 'react-native-gesture-handler'; export default function RootLayout() { return ( {/* Your app content */} ); } ``` ## Usage ```tsx import { BottomSheet, useBottomSheet } from '@/components/ui/bottom-sheet'; ``` ```tsx function MyComponent() { const { isVisible, open, close } = useBottomSheet(); return ( <> Your content here ); } ``` ## Examples #### Default **Example:** A basic bottom sheet with gesture support and snap points ```tsx // components/demo/bottom-sheet/bottom-sheet-demo.tsx import { BottomSheet, useBottomSheet } from '@/components/ui/bottom-sheet'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function BottomSheetDemo() { const { isVisible, open, close } = useBottomSheet(); return ( Welcome to Bottom Sheet This is a basic bottom sheet that supports gesture interactions. You can drag it up and down to different snap points, or swipe down quickly to dismiss it. ); } ``` #### With Title **Example:** Bottom sheet with a title header ```tsx // components/demo/bottom-sheet/bottom-sheet-title.tsx import { BottomSheet, useBottomSheet } from '@/components/ui/bottom-sheet'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function BottomSheetTitle() { const { isVisible, open, close } = useBottomSheet(); return ( This bottom sheet includes a title in the header area. The title is centered and uses the theme's title text style. ); } ``` #### Custom Snap Points **Example:** Bottom sheet with custom snap point configurations ```tsx // components/demo/bottom-sheet/bottom-sheet-snap-points.tsx import { BottomSheet, useBottomSheet } from '@/components/ui/bottom-sheet'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function BottomSheetSnapPoints() { const { isVisible, open, close } = useBottomSheet(); return ( Multiple Snap Points This sheet has four different snap points: 20%, 50%, 80%, and 95% of screen height. Try dragging to see how it snaps to each position. Available heights: • 20% - Peek view • 50% - Medium height • 80% - Large view • 95% - Nearly fullscreen ); } ``` #### Form Content **Example:** Bottom sheet containing form elements and inputs ```tsx // components/demo/bottom-sheet/bottom-sheet-form.tsx import { BottomSheet, useBottomSheet } from '@/components/ui/bottom-sheet'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function BottomSheetForm() { const { isVisible, open, close } = useBottomSheet(); const [name, setName] = useState(''); const [email, setEmail] = useState(''); const handleSubmit = () => { // Handle form submission console.log('Form submitted:', { name, email }); close(); }; return ( Name Email ); } ``` #### List Content **Example:** Bottom sheet with scrollable list content ```tsx // components/demo/bottom-sheet/bottom-sheet-list.tsx import { BottomSheet, useBottomSheet } from '@/components/ui/bottom-sheet'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; import { FlatList, TouchableOpacity } from 'react-native'; const items = [ { id: '1', title: 'Photos', subtitle: '1,234 items' }, { id: '2', title: 'Videos', subtitle: '56 items' }, { id: '3', title: 'Documents', subtitle: '89 items' }, { id: '4', title: 'Audio', subtitle: '23 items' }, { id: '5', title: 'Downloads', subtitle: '12 items' }, { id: '6', title: 'Archives', subtitle: '4 items' }, ]; export function BottomSheetList() { const { isVisible, open, close } = useBottomSheet(); const renderItem = ({ item }: { item: (typeof items)[0] }) => ( console.log('Selected:', item.title)} > {item.title} {item.subtitle} ); return ( item.id} showsVerticalScrollIndicator={false} /> ); } ``` #### No Backdrop Dismiss **Example:** Bottom sheet that cannot be dismissed by tapping backdrop ```tsx // components/demo/bottom-sheet/bottom-sheet-no-dismiss.tsx import { BottomSheet, useBottomSheet } from '@/components/ui/bottom-sheet'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function BottomSheetNoDismiss() { const { isVisible, open, close } = useBottomSheet(); return ( This bottom sheet cannot be dismissed by tapping the backdrop. You must use one of the action buttons below. This is useful for critical confirmations or required actions. ); } ``` #### Custom Styling **Example:** Bottom sheet with custom styling and colors ```tsx // components/demo/bottom-sheet/bottom-sheet-styled.tsx import { BottomSheet, useBottomSheet } from '@/components/ui/bottom-sheet'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import React from 'react'; export function BottomSheetStyled() { const { isVisible, open, close } = useBottomSheet(); const accentColor = useColor('blue'); return ( Premium Feature This bottom sheet has custom styling including a colored border and accent-colored content areas. ); } ``` #### Menu Options **Example:** Bottom sheet used as a menu with action items ```tsx // components/demo/bottom-sheet/bottom-sheet-menu.tsx import { BottomSheet, useBottomSheet } from '@/components/ui/bottom-sheet'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { TouchableOpacity } from 'react-native'; const menuItems = [ { id: 'edit', title: 'Edit', icon: '✏️' }, { id: 'share', title: 'Share', icon: '📤' }, { id: 'copy', title: 'Copy Link', icon: '🔗' }, { id: 'bookmark', title: 'Bookmark', icon: '🔖' }, { id: 'delete', title: 'Delete', icon: '🗑️', destructive: true }, ]; export function BottomSheetMenu() { const { isVisible, open, close } = useBottomSheet(); const textColor = useColor('text'); const destructiveColor = useColor('destructive'); const handleMenuAction = (action: string) => { console.log('Menu action:', action); close(); }; return ( {menuItems.map((item, index) => ( handleMenuAction(item.id)} > {item.icon} {item.title} ))} ); } ``` ## API Reference ### BottomSheet The main bottom sheet component that provides a modal interface sliding from the bottom. | Prop | Type | Default | Description | | ----------------------- | ----------- | ----------------- | --------------------------------------------------------- | | `isVisible` | `boolean` | - | Controls the visibility of the bottom sheet. | | `onClose` | `function` | - | Callback function called when the sheet is closed. | | `children` | `ReactNode` | - | The content to display inside the bottom sheet. | | `snapPoints` | `number[]` | `[0.3, 0.6, 0.9]` | Array of snap points as percentages of screen height. | | `enableBackdropDismiss` | `boolean` | `true` | Whether tapping the backdrop should dismiss the sheet. | | `title` | `string` | - | Optional title to display at the top of the sheet. | | `style` | `ViewStyle` | - | Additional styles to apply to the bottom sheet container. | ### useBottomSheet Hook A custom hook that provides state management for the bottom sheet. ```tsx const { isVisible, open, close, toggle } = useBottomSheet(); ``` #### Returns | Property | Type | Description | | ----------- | ---------- | ---------------------------------------- | | `isVisible` | `boolean` | Current visibility state of the sheet. | | `open` | `function` | Function to open the bottom sheet. | | `close` | `function` | Function to close the bottom sheet. | | `toggle` | `function` | Function to toggle the sheet visibility. | ## Gesture Support The BottomSheet component includes built-in gesture support: - **Pan Gesture**: Drag the sheet up and down to resize - **Snap Points**: The sheet will snap to predefined heights - **Velocity Detection**: Fast downward swipes will close the sheet - **Boundary Limits**: Prevents dragging beyond defined limits ## Snap Points Snap points define the available heights for the bottom sheet as percentages of screen height: - `0.3` = 30% of screen height - `0.6` = 60% of screen height - `0.9` = 90% of screen height The sheet will automatically snap to the nearest point when gestures end. ## Animation The component uses React Native Reanimated for smooth animations: - **Spring Animation**: Natural feeling spring animations for opening/closing - **Gesture Responsiveness**: Real-time tracking of pan gestures - **Backdrop Fade**: Smooth opacity transitions for the backdrop ## Accessibility The BottomSheet component includes accessibility features: - **Modal Semantics**: Proper modal behavior for screen readers - **Focus Management**: Traps focus within the sheet when open - **Gesture Alternatives**: Provides non-gesture ways to interact - **Backdrop Dismiss**: Can be disabled for better accessibility control ## Best Practices 1. **Content Height**: Ensure your content works well with different snap points 2. **Backdrop Dismiss**: Consider disabling for critical actions 3. **Loading States**: Show loading indicators for async content 4. **Error Handling**: Provide clear error states within the sheet 5. **Keyboard Handling**: Account for keyboard appearance with form content # Button > A versatile button component with multiple variants, sizes, and interactive animations. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/button - Markdown: https://ui.ahmedbna.com/docs/components/button.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/button.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/button.json - Install: `npx bna-ui add button` - npm dependencies: `expo-haptics`, `lucide-react-native`, `react-native-reanimated`, `react-native-svg`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `useHaptics`, `globals`, `text`, `view`, `icon`, `spinner` - Preview recording: https://demo.ahmedbna.com/0073-button-demo.MP4 --- **Example:** A basic button with default styling ```tsx // components/demo/button/button-demo.tsx import { Button } from '@/components/ui/button'; import React from 'react'; export function ButtonDemo() { return ( ); } ``` ## Installation ### CLI ```bash npx bna-ui add button ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install react-native-reanimated react-native-gesture-handler lucide-react-native ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/button.tsx import { Icon } from '@/components/ui/icon'; import { ButtonSpinner, SpinnerVariant } from '@/components/ui/spinner'; import { Text } from '@/components/ui/text'; import { useColor } from '@/hooks/useColor'; import { useHaptics } from '@/hooks/useHaptics'; import { CORNERS, FONT_SIZE, HEIGHT } from '@/theme/globals'; import { LucideProps } from 'lucide-react-native'; import { forwardRef } from 'react'; import { Pressable, TextStyle, TouchableOpacity, TouchableOpacityProps, View, ViewStyle, } from 'react-native'; import Animated, { useAnimatedStyle, useSharedValue, withSpring, } from 'react-native-reanimated'; export type ButtonVariant = | 'default' | 'destructive' | 'success' | 'outline' | 'secondary' | 'ghost' | 'link'; export type ButtonSize = 'default' | 'sm' | 'lg' | 'icon'; export interface ButtonProps extends Omit { label?: string; children?: React.ReactNode; animation?: boolean; haptic?: boolean; icon?: React.ComponentType; onPress?: () => void; variant?: ButtonVariant; size?: ButtonSize; disabled?: boolean; loading?: boolean; loadingVariant?: SpinnerVariant; style?: ViewStyle | ViewStyle[]; textStyle?: TextStyle; } export const Button = forwardRef( ( { children, icon, onPress, variant = 'default', size = 'default', disabled = false, loading = false, animation = true, haptic = true, loadingVariant = 'default', style, textStyle, label, ...props }, ref ) => { const feedback = useHaptics(haptic); const primaryColor = useColor('primary'); const primaryForegroundColor = useColor('primaryForeground'); const secondaryColor = useColor('secondary'); const secondaryForegroundColor = useColor('secondaryForeground'); const destructiveColor = useColor('red'); const destructiveForegroundColor = useColor('destructiveForeground'); const greenColor = useColor('green'); const borderColor = useColor('border'); // Animation values for liquid glass effect const scale = useSharedValue(1); const brightness = useSharedValue(1); const getButtonStyle = (): ViewStyle => { const baseStyle: ViewStyle = { borderRadius: CORNERS, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', }; // Size variants switch (size) { case 'sm': Object.assign(baseStyle, { height: 44, paddingHorizontal: 24 }); break; case 'lg': Object.assign(baseStyle, { height: 54, paddingHorizontal: 36 }); break; case 'icon': Object.assign(baseStyle, { height: HEIGHT, width: HEIGHT, paddingHorizontal: 0, }); break; default: Object.assign(baseStyle, { height: HEIGHT, paddingHorizontal: 32 }); } // Variant styles switch (variant) { case 'destructive': return { ...baseStyle, backgroundColor: destructiveColor }; case 'success': return { ...baseStyle, backgroundColor: greenColor }; case 'outline': return { ...baseStyle, backgroundColor: 'transparent', borderWidth: 1, borderColor, }; case 'secondary': return { ...baseStyle, backgroundColor: secondaryColor }; case 'ghost': return { ...baseStyle, backgroundColor: 'transparent' }; case 'link': return { ...baseStyle, backgroundColor: 'transparent', height: 'auto', paddingHorizontal: 0, }; default: return { ...baseStyle, backgroundColor: primaryColor }; } }; const getButtonTextStyle = (): TextStyle => { const baseTextStyle: TextStyle = { fontSize: FONT_SIZE, fontWeight: '500', }; switch (variant) { case 'destructive': return { ...baseTextStyle, color: destructiveForegroundColor }; case 'success': return { ...baseTextStyle, color: destructiveForegroundColor }; case 'outline': return { ...baseTextStyle, color: primaryColor }; case 'secondary': return { ...baseTextStyle, color: secondaryForegroundColor }; case 'ghost': return { ...baseTextStyle, color: primaryColor }; case 'link': return { ...baseTextStyle, color: primaryColor, textDecorationLine: 'underline', }; default: return { ...baseTextStyle, color: primaryForegroundColor }; } }; const getColor = (): string => { switch (variant) { case 'destructive': return destructiveForegroundColor; case 'success': return destructiveForegroundColor; case 'outline': return primaryColor; case 'secondary': return secondaryForegroundColor; case 'ghost': return primaryColor; case 'link': return primaryColor; default: return primaryForegroundColor; } }; // Helper function to get icon size based on button size const getIconSize = (): number => { switch (size) { case 'sm': return 16; case 'lg': return 24; case 'icon': return 20; default: return 18; } }; // Trigger haptic feedback const triggerHapticFeedback = () => { if (!disabled && !loading) { feedback('impact-light'); } }; // Improved animation handlers for liquid glass effect. // These are deliberately not worklets: Pressable dispatches them on the JS // thread, and both the haptic call and `props.onPressIn` are JS-only. // Writing to a shared value from JS is fine — Reanimated still runs the // spring on the UI thread. const handlePressIn = (ev?: any) => { // Trigger haptic feedback triggerHapticFeedback(); // Scale up with bouncy spring animation scale.value = withSpring(1.05, { damping: 15, stiffness: 400, mass: 0.5, }); // Slight brightness increase for glass effect brightness.value = withSpring(1.1, { damping: 20, stiffness: 300, }); // Call original onPressIn if provided props.onPressIn?.(ev); }; const handlePressOut = (ev?: any) => { // Return to original size with smooth spring scale.value = withSpring(1, { damping: 20, stiffness: 400, mass: 0.8, overshootClamping: false, }); // Return brightness to normal brightness.value = withSpring(1, { damping: 20, stiffness: 300, }); // Call original onPressOut if provided props.onPressOut?.(ev); }; // Handle actual press action const handlePress = () => { if (onPress && !disabled && !loading) { onPress(); } }; // Handle press for TouchableOpacity (non-animated version) const handleTouchablePress = () => { triggerHapticFeedback(); handlePress(); }; // Animated styles using useAnimatedStyle const animatedStyle = useAnimatedStyle(() => { return { transform: [{ scale: scale.value }], opacity: brightness.value * (disabled ? 0.5 : 1), }; }); // Extract flex value from style prop const getFlexFromStyle = () => { if (!style) return null; const styleArray = Array.isArray(style) ? style : [style]; // Find the last occurrence of flex (in case of multiple styles with flex) for (let i = styleArray.length - 1; i >= 0; i--) { const s = styleArray[i]; if (s && typeof s === 'object' && 'flex' in s) { return s.flex; } } return null; }; // Alternative simpler solution - replace flex with alignSelf const getPressableStyle = (): ViewStyle => { const flexValue = getFlexFromStyle(); // If flex: 1 is applied, use alignSelf: 'stretch' instead to only affect width return flexValue === 1 ? { flex: 1, alignSelf: 'stretch', } : flexValue !== null ? { flex: flexValue, maxHeight: size === 'sm' ? 44 : size === 'lg' ? 54 : HEIGHT, } : {}; }; // Updated getStyleWithoutFlex function const getStyleWithoutFlex = () => { if (!style) return style; const styleArray = Array.isArray(style) ? style : [style]; return styleArray.map((s) => { if (s && typeof s === 'object' && 'flex' in s) { const { flex, ...restStyle } = s; return restStyle; } return s; }); }; const buttonStyle = getButtonStyle(); const finalTextStyle = getButtonTextStyle(); const contentColor = getColor(); const iconSize = getIconSize(); const styleWithoutFlex = getStyleWithoutFlex(); return animation ? ( {loading ? ( ) : typeof children === 'string' ? ( {icon && ( )} {children} ) : ( {icon && ( )} {children} )} ) : ( {loading ? ( ) : typeof children === 'string' ? ( {icon && } {children} ) : ( children )} ); } ); // Add display name for better debugging Button.displayName = 'Button'; ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { Button } from '@/components/ui/button'; ``` ```tsx ``` ## Examples #### Default **Example:** A basic button with default styling ```tsx // components/demo/button/button-demo.tsx import { Button } from '@/components/ui/button'; import React from 'react'; export function ButtonDemo() { return ( ); } ``` #### Variants **Example:** Buttons with different visual styles ```tsx // components/demo/button/button-variants.tsx import { Button } from '@/components/ui/button'; import { View } from '@/components/ui/view'; import React from 'react'; export function ButtonVariants() { return ( ); } ``` #### Sizes **Example:** Buttons in different sizes ```tsx // components/demo/button/button-sizes.tsx import { Button } from '@/components/ui/button'; import { View } from '@/components/ui/view'; import React from 'react'; export function ButtonSizes() { return ( ); } ``` #### With Icons **Example:** Buttons with leading icons ```tsx // components/demo/button/button-with-icons.tsx import { Button } from '@/components/ui/button'; import { View } from '@/components/ui/view'; import { Download, Mail, Plus, Search } from 'lucide-react-native'; import React from 'react'; export function ButtonWithIcons() { return ( ); } ``` #### Icon Only **Example:** Icon-only buttons for compact layouts ```tsx // components/demo/button/button-icon-only.tsx import { Button } from '@/components/ui/button'; import { View } from '@/components/ui/view'; import { Heart, MessageCircle, MoreHorizontal, Settings, Share, } from 'lucide-react-native'; import React from 'react'; export function ButtonIconOnly() { return ( ); } ``` #### Disabled States **Example:** Buttons in disabled state ```tsx // components/demo/button/button-disabled.tsx import { Button } from '@/components/ui/button'; import { View } from '@/components/ui/view'; import { Lock } from 'lucide-react-native'; import React from 'react'; export function ButtonDisabled() { return ( ); } ``` #### Animation Control **Example:** Buttons with and without animations ```tsx // components/demo/button/button-animation.tsx import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function ButtonAnimation() { return ( With Animation (default) Without Animation ); } ``` ## API Reference ### Button A pressable button component with multiple variants and states. | Prop | Type | Default | Description | | ---------------- | ---------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------- | | `children` | `ReactNode` | - | The button content (text or custom elements). | | `onPress` | `() => void` | - | Function called when the button is pressed. | | `variant` | `ButtonVariant` | `'default'` | The visual style variant of the button. | | `size` | `ButtonSize` | `'default'` | The size of the button. | | `disabled` | `boolean` | `false` | Whether the button is disabled. | | `loading` | `boolean` | `false` | Whether the button is in loading state. | | `loadingVariant` | `SpinnerVariant` | `'default'` | The variant of the loading spinner. | | `animation` | `boolean` | `true` | Whether to enable press animations. | | `haptic` | `boolean` | `true` | Whether to trigger haptic feedback on press (iOS and Android). | | `icon` | `ComponentType` | - | Icon component to display before the text. | | `label` | `string` | - | Fallback `accessibilityLabel` for screen readers, useful when `children` is not plain text (e.g. an icon-only button). | | `style` | `ViewStyle \| ViewStyle[]` | - | Additional styles for the button container. | | `textStyle` | `TextStyle` | - | Additional styles for the button text. | ### ButtonVariant The available button variants: - `'default'` - Primary button with solid background - `'destructive'` - Red button for destructive actions - `'success'` - Green button for success actions - `'outline'` - Button with border and transparent background - `'secondary'` - Secondary button with muted colors - `'ghost'` - Button with no background - `'link'` - Text-only button with underline ### ButtonSize The available button sizes: - `'default'` - Standard button size (48px height) - `'sm'` - Small button size (44px height) - `'lg'` - Large button size (54px height) - `'icon'` - Square button for icons only (48x48px) ## Animations The Button component features a liquid glass animation effect by default: - **Press Animation**: Scales up to 1.04x with a bouncy spring animation - **Brightness Effect**: Subtle brightness increase for a glass-like effect - **Smooth Transitions**: Uses `react-native-reanimated` for 60fps animations - **Customizable**: Can be disabled by setting `animation={false}` ## Accessibility The Button component is built with accessibility in mind: - Proper touch target size (minimum 44px) - Disabled state prevents interaction and reduces opacity - Loading state shows spinner with appropriate color contrast - Supports screen readers with proper accessibility labels - Responsive to system accessibility settings ## Best Practices - Use `'default'` variant for primary actions - Use `'outline'` or `'secondary'` for secondary actions - Use `'destructive'` for delete or dangerous actions - Use `'ghost'` for subtle actions or in dense layouts - Always provide meaningful `onPress` handlers - Use loading states for async operations - Ensure sufficient color contrast for text and backgrounds # Camera Preview > A comprehensive camera component with capture, preview, and media management capabilities. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/camera-preview - Markdown: https://ui.ahmedbna.com/docs/components/camera-preview.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/camera-preview.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/camera-preview.json - Install: `npx bna-ui add camera-preview` - npm dependencies: `expo-camera`, `expo-haptics`, `expo-image`, `expo-media-library`, `expo-video`, `lucide-react-native`, `react-native-gesture-handler`, `react-native-reanimated`, `react-native-safe-area-context`, `react-native-svg`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `useHaptics`, `globals`, `text`, `view`, `icon`, `spinner`, `button`, `image`, `progress`, `video`, `camera` - Preview recording: https://demo.ahmedbna.com/0082-camera-preview-demo.mov --- **Example:** A basic camera preview with capture and save functionality ```tsx // components/demo/camera-preview/camera-preview-demo.tsx import { CameraPreview } from '@/components/ui/camera-preview'; export function CameraPreviewDemo() { return ; } ``` ## Installation ### CLI ```bash npx bna-ui add camera-preview ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install expo-camera expo-media-library lucide-react-native ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/camera-preview.tsx import { Button } from '@/components/ui/button'; import { Camera, CaptureSuccess } from '@/components/ui/camera'; import { Image } from '@/components/ui/image'; import { Text } from '@/components/ui/text'; import { Video } from '@/components/ui/video'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import * as MediaLibrary from 'expo-media-library'; import { Download, Upload, X } from 'lucide-react-native'; import { useState } from 'react'; import { Alert, Dimensions, StyleSheet, TouchableOpacity } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; const { width: screenWidth } = Dimensions.get('window'); export function CameraPreview() { const [showCamera, setShowCamera] = useState(false); const [cameraHeight, setCameraHeight] = useState((screenWidth * 4) / 3); const [capturedMedia, setCapturedMedia] = useState<{ uri: string; type: 'picture' | 'video'; } | null>(null); const [showPreview, setShowPreview] = useState(false); const [mediaLibraryPermission, requestMediaLibraryPermission] = MediaLibrary.usePermissions(); const backgroundColor = useColor('background'); const cardColor = useColor('card'); const textColor = useColor('text'); const handleCapture = (results: CaptureSuccess) => { setCameraHeight(results.cameraHeight); setCapturedMedia({ type: results.type, uri: results.uri }); setShowCamera(false); setShowPreview(true); }; const handleVideoCapture = (results: CaptureSuccess) => { setCameraHeight(results.cameraHeight); setCapturedMedia({ type: results.type, uri: results.uri }); setShowCamera(false); setShowPreview(true); }; const handleOpenCamera = () => { setCapturedMedia(null); setShowPreview(false); setShowCamera(true); }; const handleCloseCamera = () => { setShowCamera(false); }; const handleRetakeMedia = () => { setCapturedMedia(null); setShowPreview(false); setShowCamera(true); }; const handleSaveToAlbum = async () => { if (!capturedMedia) return; try { // Request permission if not granted if (mediaLibraryPermission?.status !== 'granted') { const permission = await requestMediaLibraryPermission(); if (!permission.granted) { Alert.alert( 'Permission Required', 'Please grant permission to save media to your picture library.' ); return; } } // Save to media library. SDK 56 removed `saveToLibraryAsync` — it still // type-checks from the root entrypoint but throws at runtime. await MediaLibrary.Asset.create(capturedMedia.uri); Alert.alert( 'Success!', `${ capturedMedia.type === 'picture' ? 'Photo' : 'Video' } saved to your picture library.`, [ { text: 'OK', onPress: () => { setCapturedMedia(null); setShowPreview(false); }, }, ] ); } catch (error) { console.error('Error saving to album:', error); Alert.alert('Error', 'Failed to save media to your picture library.'); } }; const handleUploadAction = () => { if (!capturedMedia) return; // This is where you would implement your upload logic // For example: upload to a server, save to database, etc. const mediaDetails = { uri: capturedMedia.uri, type: capturedMedia.type, timestamp: new Date().toISOString(), // Add any other metadata you need }; console.log('Media details for upload/processing:', mediaDetails); // Example: Call your upload function // uploadToServer(mediaDetails); // saveToDatabase(mediaDetails); Alert.alert( 'Upload Action', `${ capturedMedia.type === 'picture' ? 'Photo' : 'Video' } ready for processing.\n\nCheck console for media details.`, [ { text: 'Continue', onPress: () => { // You might want to keep the preview open or close it // depending on your use case }, }, { text: 'Done', onPress: () => { // setCapturedMedia(null); // setShowPreview(false); }, }, ] ); }; // Preview Mode if (showPreview && capturedMedia) { return ( {capturedMedia.type === 'picture' && capturedMedia.uri ? ( ) : ( ); } // Camera Mode if (showCamera) { return ( ); } // Main Screen return ( Camera Component Tap the button below to open the camera and capture photos or videos. After capturing, you can preview, save, or process your media. ); } const styles = StyleSheet.create({ container: { flex: 1, }, content: { flex: 1, padding: 20, justifyContent: 'center', alignItems: 'center', }, title: { marginBottom: 16, textAlign: 'center', }, description: { textAlign: 'center', marginBottom: 32, paddingHorizontal: 20, }, lastCaptureContainer: { padding: 16, borderRadius: 12, marginBottom: 32, alignItems: 'center', maxWidth: '100%', }, lastCaptureTitle: { marginBottom: 12, }, thumbnailImage: { width: 120, height: 120, borderRadius: 8, }, videoThumbnailContainer: { position: 'relative', width: 120, height: 120, borderRadius: 8, overflow: 'hidden', }, playIconOverlay: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, justifyContent: 'center', alignItems: 'center', backgroundColor: 'rgba(0, 0, 0, 0.3)', }, playIcon: { fontSize: 24, }, viewButton: { paddingHorizontal: 16, paddingVertical: 8, borderRadius: 8, marginTop: 12, }, viewButtonText: { color: 'white', fontWeight: '600', }, buttonContainer: { width: '100%', gap: 16, alignItems: 'center', }, button: { minWidth: 200, }, previewContainer: { width: screenWidth, borderRadius: 12, overflow: 'hidden', position: 'relative', marginHorizontal: 0, }, previewMedia: { width: '100%', height: '100%', }, topFloatingButtons: { position: 'absolute', bottom: 40, left: 20, right: 20, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', }, floatingButton: { width: 56, height: 56, borderRadius: 28, justifyContent: 'center', alignItems: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 2, }, shadowOpacity: 0.25, shadowRadius: 4, elevation: 5, }, bottomActionContainer: { padding: 20, alignItems: 'center', }, uploadButton: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 32, paddingVertical: 16, borderRadius: 12, shadowColor: '#000', shadowOffset: { width: 0, height: 2, }, shadowOpacity: 0.15, shadowRadius: 4, elevation: 3, }, uploadIcon: { marginRight: 12, }, uploadButtonText: { color: 'white', fontSize: 18, fontWeight: '600', }, mediaInfo: { alignItems: 'center', paddingHorizontal: 20, paddingBottom: 20, }, mediaInfoText: { fontSize: 16, fontWeight: '600', marginBottom: 4, }, mediaInfoSubtext: { fontSize: 14, textAlign: 'center', }, }); ``` **3.** Update the import paths to match your project setup. **4.** Add camera and media library permissions to your app.json: ```json { "expo": { "plugins": [ [ "expo-camera", { "cameraPermission": "Allow $(PRODUCT_NAME) to access your camera", "microphonePermission": "Allow $(PRODUCT_NAME) to access your microphone", "recordAudioAndroid": true } ], [ "expo-media-library", { "photosPermission": "Allow $(PRODUCT_NAME) to access your photos.", "savePhotosPermission": "Allow $(PRODUCT_NAME) to save photos.", "isAccessMediaLocationEnabled": true } ] ] } } ``` ## Usage ```tsx import { CameraPreview } from '@/components/ui/camera-preview'; ``` ```tsx ``` ## Examples #### Default **Example:** A basic camera preview with capture and save functionality ```tsx // components/demo/camera-preview/camera-preview-demo.tsx import { CameraPreview } from '@/components/ui/camera-preview'; export function CameraPreviewDemo() { return ; } ``` ## Features ### Camera Capabilities - **Photo Capture**: High-quality photo capture with multiple resolution options - **Video Recording**: Full video recording with audio support - **Flash Control**: Built-in torch/flash toggle functionality - **Camera Switching**: Front and back camera switching - **Focus Control**: Tap-to-focus functionality ### Media Management - **Preview Mode**: Full-screen preview of captured media - **Save to Gallery**: Direct save to device photo library - **Custom Upload**: Configurable upload handling - **Media Processing**: Ready for custom processing workflows ### User Experience - **Responsive Design**: Adapts to different screen sizes - **Theme Support**: Automatic light/dark theme support - **Permission Handling**: Graceful permission request flows - **Error Handling**: Comprehensive error handling and user feedback ## API Reference ### CameraPreview `CameraPreview` takes **no props**. It's a self-contained, full-screen camera screen that hardcodes its own configuration internally (back camera, torch and video capture enabled) and manages capture, preview, and save-to-gallery state on its own. It composes the registry's own `Camera` and `Video` components — there is no `CameraPreviewProps` type, no `onCapture`/`onError`/ `onPermission` events, and no `MediaDetails` type to import. If you need a configurable camera with props and callbacks, use [`Camera`](/docs/components/camera) directly and build your own preview flow around it. ## Permissions The Camera Preview component requires the following permissions: ### iOS - **Camera**: Required for photo and video capture - **Microphone**: Required for video recording with audio - **Photo Library**: Required for saving media to gallery ### Android - **CAMERA**: Required for photo and video capture - **RECORD\_AUDIO**: Required for video recording with audio - **WRITE\_EXTERNAL\_STORAGE**: Required for saving media - **READ\_EXTERNAL\_STORAGE**: Required for accessing saved media ## Best Practices ### Performance - Use appropriate quality settings for your use case - Implement proper cleanup when component unmounts - Handle memory management for large media files - Use compression for uploaded media when appropriate ### User Experience - Always request permissions gracefully - Provide clear feedback during capture and processing - Implement proper loading states - Handle edge cases like low storage space ### Security - Validate uploaded media on your backend - Implement proper file type checking - Consider implementing media scanning for inappropriate content - Use secure upload endpoints with proper authentication ## Accessibility The Camera Preview component is built with accessibility in mind: - Screen reader support for all interactive elements - High contrast mode support - Voice-over announcements for capture events - Keyboard navigation support where applicable - Proper focus management throughout the interface ## Troubleshooting ### Common Issues **Camera not working on iOS simulator** - The iOS simulator doesn't support camera functionality - Test on a physical device for full functionality **Permission denied errors** - Ensure permissions are properly configured in app.json - Check that users have granted necessary permissions - Implement graceful fallbacks for denied permissions **Media not saving to gallery** - Verify Media Library permissions are granted - Check available storage space - Ensure proper error handling is implemented **Video recording issues** - Verify microphone permissions for audio recording - Check maximum duration settings - Monitor memory usage during long recordings # Camera > A powerful camera component with advanced features like zoom, timer, torch, and video recording. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/camera - Markdown: https://ui.ahmedbna.com/docs/components/camera.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/camera.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/camera.json - Install: `npx bna-ui add camera` - npm dependencies: `expo-camera`, `expo-haptics`, `lucide-react-native`, `react-native-gesture-handler`, `react-native-reanimated`, `react-native-svg`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `useHaptics`, `text`, `view`, `icon`, `spinner`, `button`, `progress` - Preview recording: https://demo.ahmedbna.com/0083-camera-demo.mov --- **Example:** A basic camera with default settings ```tsx // components/demo/camera/camera-demo.tsx import { Camera } from '@/components/ui/camera'; import React from 'react'; import { Alert } from 'react-native'; export function CameraDemo() { const handleCapture = ({ uri, type }: { uri: string; type: string }) => { Alert.alert('Picture Captured', `Saved to: ${uri}`); }; const handleVideoCapture = ({ uri, type }: { uri: string; type: string }) => { Alert.alert('Video Recorded', `Saved to: ${uri}`); }; return ( ); } ``` ## Installation ### CLI ```bash npx bna-ui add camera ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install expo-camera react-native-gesture-handler lucide-react-native ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/camera.tsx import { Button } from '@/components/ui/button'; import { Progress } from '@/components/ui/progress'; import { Text } from '@/components/ui/text'; import { useColor } from '@/hooks/useColor'; import { BORDER_RADIUS, FONT_SIZE } from '@/theme/globals'; import { CameraMode, CameraRatio, CameraType, CameraView, useCameraPermissions, } from 'expo-camera'; import { Camera as CameraIcon, Grid3X3, Settings, SwitchCamera, Timer, Video, Volume2, VolumeX, X, Zap, ZapOff, } from 'lucide-react-native'; import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState, } from 'react'; import { ActivityIndicator, Alert, Dimensions, StyleSheet, TouchableOpacity, View, ViewStyle, } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { interpolate, runOnJS, useAnimatedProps, useAnimatedReaction, useAnimatedStyle, useSharedValue, withDelay, withSequence, withTiming, } from 'react-native-reanimated'; const { width: screenWidth } = Dimensions.get('window'); const AnimatedCameraView = Animated.createAnimatedComponent(CameraView); export type CaptureSuccess = { type: CameraMode; uri: string; cameraHeight: number; }; export interface CameraProps { style?: ViewStyle; facing?: CameraType; enableTorch?: boolean; showControls?: boolean; timerOptions?: Array; enableVideo?: boolean; maxVideoDuration?: number; // in seconds onClose?: () => void; onCapture?: ({ type, uri, cameraHeight }: CaptureSuccess) => void; onVideoCapture?: ({ type, uri, cameraHeight }: CaptureSuccess) => void; } export interface CameraRef { switchCamera: () => void; toggleTorch: () => void; takePicture: () => Promise; startRecording: () => Promise; stopRecording: () => Promise; } export const Camera = forwardRef( ( { style, onCapture, onVideoCapture, onClose, enableTorch = true, showControls = true, enableVideo = true, maxVideoDuration = 60, timerOptions = [0, 3, 10], facing: initialFacing = 'back', }, ref ) => { const cameraRef = useRef(null); const recordingInterval = useRef | null>( null ); const timerInterval = useRef | null>(null); const fadeAnim = useSharedValue(0); const settingsAnim = useSharedValue(0); const zoomTextAnim = useSharedValue(0); const zoomControlsAnim = useSharedValue(0); const zoom = useSharedValue(0); const baseZoom = useSharedValue(0); const aspectRatios: Array = ['16:9', '4:3', '1:1']; const [permission, requestPermission] = useCameraPermissions(); const [torch, setTorch] = useState(false); const [isCapturing, setIsCapturing] = useState(false); const [isRecording, setIsRecording] = useState(false); const [recordingTime, setRecordingTime] = useState(0); const [mode, setMode] = useState('picture'); const [facing, setFacing] = useState(initialFacing); const [showGrid, setShowGrid] = useState(false); const [timerSeconds, setTimerSeconds] = useState(0); const [selectedTimer, setSelectedTimer] = useState(0); const [isTimerActive, setIsTimerActive] = useState(false); const [soundEnabled, setSoundEnabled] = useState(true); const [showSettings, setShowSettings] = useState(false); const [aspectRatioIndex, setAspectRatioIndex] = useState(1); const [zoomControls, setZoomControls] = useState(false); const [availableZoomFactors] = useState([ 0, 0.25, 0.5, 0.75, 1.0, ]); const [currentZoomIndex, setCurrentZoomIndex] = useState(0); const [zoomFactorText, setZoomFactorText] = useState('1×'); const [zoomProgress, setZoomProgress] = useState(0); const backgroundColor = useColor('background'); const textColor = useColor('text'); const primaryColor = useColor('primary'); const cardColor = useColor('card'); const destructiveColor = useColor('destructive'); useAnimatedReaction( () => zoom.value, (currentValue) => { const text = currentValue === 0 ? '1×' : `${(1 + currentValue * 4).toFixed(1)}×`; // Adjusted to .toFixed(1) for smoother feedback runOnJS(setZoomFactorText)(text); runOnJS(setZoomProgress)(currentValue * 100); }, [] ); const animatedContainerStyle = useAnimatedStyle(() => ({ opacity: fadeAnim.value, })); const animatedSettingsStyle = useAnimatedStyle(() => ({ opacity: settingsAnim.value, transform: [ { translateY: interpolate(settingsAnim.value, [0, 1], [-100, 0]) }, ], })); const animatedZoomTextStyle = useAnimatedStyle(() => ({ opacity: zoomTextAnim.value, })); const animatedZoomControlsStyle = useAnimatedStyle(() => ({ opacity: zoomControlsAnim.value, })); const animatedCameraProps = useAnimatedProps(() => ({ zoom: zoom.value })); const pinchGesture = Gesture.Pinch() .onStart(() => { 'worklet'; // Save the current zoom level when the pinch gesture begins baseZoom.value = zoom.value; }) .onUpdate((event) => { 'worklet'; // Calculate new zoom based on the starting zoom and the current scale // The sensitivity factor (e.g., * 0.5) can be adjusted for feel const newZoom = baseZoom.value + (event.scale - 1) * 0.5; // Clamp the zoom value between 0 and 1 zoom.value = Math.min(Math.max(newZoom, 0), 1); }) .onEnd(() => { 'worklet'; // We no longer need to set baseZoom here. // Just animate the indicator. zoomTextAnim.value = withSequence( withTiming(1, { duration: 200 }), withDelay(1000, withTiming(0, { duration: 200 })) ); }); const doubleTapGesture = Gesture.Tap() .numberOfTaps(2) .onEnd(() => { 'worklet'; const newZoom = zoom.value > 0 ? 0 : 0.5; zoom.value = withTiming(newZoom); baseZoom.value = newZoom; // Keep this for double tap, as it's an instant change zoomTextAnim.value = withSequence( withTiming(1, { duration: 200 }), withDelay(1000, withTiming(0, { duration: 200 })) ); }); const composedGestures = Gesture.Simultaneous( pinchGesture, doubleTapGesture ); useImperativeHandle(ref, () => ({ switchCamera: toggleCameraFacing, toggleTorch, takePicture: handleCapture, startRecording: handleStartRecording, stopRecording: handleStopRecording, })); useEffect(() => { fadeAnim.value = withTiming(1, { duration: 300 }); }, [fadeAnim]); useEffect(() => { zoomControlsAnim.value = withTiming(zoomControls ? 1 : 0, { duration: 300, }); }, [zoomControls, zoomControlsAnim]); useEffect(() => { return () => { if (recordingInterval.current) clearInterval(recordingInterval.current); if (timerInterval.current) clearInterval(timerInterval.current); }; }, []); const getCameraHeight = () => { const currentAspectRatio = aspectRatios[aspectRatioIndex]; switch (currentAspectRatio) { case '16:9': return (screenWidth * 16) / 9; case '1:1': return screenWidth; case '4:3': default: return (screenWidth * 4) / 3; } }; const startTimer = (seconds: number) => { setTimerSeconds(seconds); setIsTimerActive(true); timerInterval.current = setInterval(() => { setTimerSeconds((prev) => { if (prev <= 1) { setIsTimerActive(false); if (timerInterval.current) clearInterval(timerInterval.current); setTimeout(() => { if (mode === 'picture') handleActualCapture(); else handleStartRecording(); }, 100); return 0; } return prev - 1; }); }, 1000); }; const cancelTimer = () => { if (timerInterval.current) clearInterval(timerInterval.current); setIsTimerActive(false); setTimerSeconds(0); }; const handleActualCapture = async () => { if (!cameraRef.current || isCapturing || isRecording) return; try { setIsCapturing(true); const picture = await cameraRef.current.takePictureAsync({ quality: 1, base64: false, exif: true, }); if (picture && onCapture) onCapture({ type: 'picture', uri: picture.uri, cameraHeight: getCameraHeight(), }); } catch (error) { console.error('Error taking picture:', error); Alert.alert('Error', 'Failed to take picture'); } finally { setIsCapturing(false); } }; const handleStartRecording = async () => { if (!cameraRef.current || isRecording || isCapturing) return; try { setIsRecording(true); setRecordingTime(0); recordingInterval.current = setInterval(() => { setRecordingTime((prev) => { if (prev >= maxVideoDuration) { handleStopRecording(); return prev; } return prev + 1; }); }, 1000); const video = await cameraRef.current.recordAsync({ maxDuration: maxVideoDuration, }); if (video && onVideoCapture) onVideoCapture({ type: 'video', uri: video.uri, cameraHeight: getCameraHeight(), }); } catch (error) { console.error('Error starting recording:', error); Alert.alert('Error', 'Failed to start recording'); setIsRecording(false); } }; const handleCapture = async () => { if (isCapturing || isRecording || isTimerActive) return; if (selectedTimer > 0) startTimer(selectedTimer); else if (mode === 'picture') handleActualCapture(); else handleStartRecording(); }; const handleStopRecording = async () => { if (!cameraRef.current || !isRecording) return; try { await cameraRef.current.stopRecording(); if (recordingInterval.current) clearInterval(recordingInterval.current); } catch (error) { console.error('Error stopping recording:', error); } finally { setIsRecording(false); setRecordingTime(0); } }; const toggleCameraFacing = () => setFacing((c) => (c === 'back' ? 'front' : 'back')); const toggleTorch = () => setTorch((c) => !c); const toggleMode = () => { if (!isRecording && !isCapturing) setMode((c) => (c === 'picture' ? 'video' : 'picture')); }; const toggleSettings = () => { setShowSettings((prev) => { const newValue = !prev; settingsAnim.value = withTiming(newValue ? 1 : 0, { duration: 300 }); return newValue; }); }; const handleZoomSliderChange = (value: number) => { const newZoom = value / 100; zoom.value = newZoom; baseZoom.value = newZoom; }; const formatTime = (seconds: number) => { const mins = Math.floor(seconds / 60); const secs = seconds % 60; return `${mins.toString().padStart(2, '0')}:${secs .toString() .padStart(2, '0')}`; }; const getTimerButtonText = () => selectedTimer === 0 ? 'OFF' : `${selectedTimer}s`; const handleZoomButtonTap = () => { const nextIndex = (currentZoomIndex + 1) % availableZoomFactors.length; const nextZoom = availableZoomFactors[nextIndex]; setCurrentZoomIndex(nextIndex); zoom.value = withTiming(nextZoom); baseZoom.value = nextZoom; zoomTextAnim.value = withSequence( withTiming(1, { duration: 200 }), withDelay(1000, withTiming(0, { duration: 200 })) ); }; if (!permission) { return ( Loading camera... ); } if (!permission.granted) { return ( Camera Access Required We need access to your camera to take pictures and videos ); } return ( {/* Children of CameraView are rendered as an overlay */} {showGrid && ( )} {zoomFactorText} {isTimerActive && ( {timerSeconds} Tap to cancel )} {isRecording && ( REC {formatTime(recordingTime)} )} {showControls && ( <> {onClose && ( )} {mode.toUpperCase()} setShowGrid(!showGrid)} accessibilityRole='button' accessibilityLabel='Toggle grid overlay' accessibilityState={{ selected: showGrid }} > setSoundEnabled(!soundEnabled)} accessibilityRole='button' accessibilityLabel='Toggle sound' accessibilityState={{ selected: soundEnabled }} > {soundEnabled ? ( ) : ( )} setAspectRatioIndex((p) => (p + 1) % 3)} > {aspectRatios[aspectRatioIndex]} 0 ? primaryColor : cardColor, }, ]} onPress={() => { const ci = timerOptions.indexOf(selectedTimer); const ni = (ci + 1) % timerOptions.length; setSelectedTimer(timerOptions[ni]); }} > 0 ? cardColor : textColor} /> 0 ? cardColor : textColor, }, ]} > {getTimerButtonText()} {enableTorch && facing === 'back' && ( {torch ? ( ) : ( )} )} {zoomFactorText} {enableVideo && ( {mode === 'picture' ? ( )} {isCapturing ? ( ) : ( )} )} ); } ); Camera.displayName = 'Camera'; const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'center', alignItems: 'center', }, cameraContainer: { width: screenWidth, borderRadius: BORDER_RADIUS, overflow: 'hidden', }, camera: { flex: 1, }, topControls: { position: 'absolute', top: 20, left: 20, right: 20, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', zIndex: 1, }, topLeft: { flex: 1, alignItems: 'flex-start', }, topCenter: { flex: 1, alignItems: 'center', }, topRight: { flex: 1, alignItems: 'flex-end', }, modeText: { fontSize: 16, fontWeight: 'bold', textShadowColor: 'rgba(0, 0, 0, 0.5)', textShadowOffset: { width: 1, height: 1 }, textShadowRadius: 2, }, settingsPanel: { position: 'absolute', top: 76, left: 20, right: 20, borderRadius: BORDER_RADIUS, padding: 16, zIndex: 2, }, settingsRow: { flexDirection: 'row', justifyContent: 'space-around', alignItems: 'center', }, settingButton: { width: 48, height: 48, borderRadius: 24, justifyContent: 'center', alignItems: 'center', }, settingText: { fontSize: 12, fontWeight: 'bold', }, timerSettingText: { fontSize: 10, fontWeight: 'bold', marginTop: 2, }, sideControls: { position: 'absolute', right: 20, top: '50%', transform: [{ translateY: -120 }], gap: 16, zIndex: 1, }, bottomControls: { position: 'absolute', bottom: 40, left: 20, right: 20, flexDirection: 'row', justifyContent: 'center', alignItems: 'center', zIndex: 1, }, controlButton: { width: 48, height: 48, borderRadius: 24, justifyContent: 'center', alignItems: 'center', backgroundColor: 'rgba(0, 0, 0, 0.5)', }, captureButton: { width: 80, height: 80, borderRadius: 40, borderWidth: 4, justifyContent: 'center', alignItems: 'center', backgroundColor: 'white', }, captureInner: { width: 32, height: 32, borderRadius: 30, }, capturingButton: { transform: [{ scale: 0.9 }], }, gridOverlay: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, zIndex: 1, }, gridLines: { flex: 1, position: 'relative', }, gridLine: { position: 'absolute', backgroundColor: 'rgba(255, 255, 255, 0.3)', }, verticalLine1: { left: '33.33%', top: 0, bottom: 0, width: 1, }, verticalLine2: { left: '66.66%', top: 0, bottom: 0, width: 1, }, horizontalLine1: { top: '33.33%', left: 0, right: 0, height: 1, }, horizontalLine2: { top: '66.66%', left: 0, right: 0, height: 1, }, zoomIndicator: { position: 'absolute', top: '45%', alignSelf: 'center', backgroundColor: 'rgba(0, 0, 0, 0.7)', paddingHorizontal: 16, paddingVertical: 8, borderRadius: 20, zIndex: 2, }, zoomText: { color: 'white', fontSize: 16, fontWeight: 'bold', textAlign: 'center', }, timerOverlay: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0, 0, 0, 0.5)', justifyContent: 'center', alignItems: 'center', zIndex: 3, }, timerText: { fontSize: 72, fontWeight: 'bold', color: 'white', textAlign: 'center', }, cancelTimerButton: { position: 'absolute', top: 60, right: 20, width: 48, height: 48, borderRadius: 24, backgroundColor: 'rgba(0, 0, 0, 0.7)', justifyContent: 'center', alignItems: 'center', }, tapToCancelText: { position: 'absolute', bottom: 100, color: 'white', fontSize: 16, textAlign: 'center', }, recordingIndicator: { position: 'absolute', top: 20, left: 20, flexDirection: 'row', alignItems: 'center', backgroundColor: 'rgba(255, 0, 0, 0.8)', paddingHorizontal: 12, paddingVertical: 6, borderRadius: 16, zIndex: 2, }, recordingDot: { width: 8, height: 8, borderRadius: 4, backgroundColor: 'white', marginRight: 8, }, recordingText: { color: 'white', fontSize: 14, fontWeight: 'bold', }, permissionContainer: { flex: 1, gap: 16, padding: 32, borderRadius: BORDER_RADIUS, justifyContent: 'center', alignItems: 'center', }, permissionIcon: { marginBottom: 16, }, loadingText: { marginTop: 16, fontSize: FONT_SIZE, }, zoomControls: { position: 'absolute', right: 20, top: '25%', padding: 12, borderRadius: 12, justifyContent: 'center', alignItems: 'center', zIndex: 100, }, sliderContainer: { height: 200, justifyContent: 'space-between', alignItems: 'center', paddingVertical: 10, transform: [{ rotate: '-90deg' }], }, zoomSlider: { width: 160, borderRadius: 999, }, zoomValue: { fontSize: 14, fontWeight: 'bold', }, currentZoomText: { marginTop: 12, fontSize: 12, fontWeight: '600', }, }); export Camera; ``` **3.** Update the import paths to match your project setup. **4.** Configure permissions in your app.json or expo.json: ```json { "expo": { "plugins": [ [ "expo-camera", { "cameraPermission": "Allow $(PRODUCT_NAME) to access your camera", "microphonePermission": "Allow $(PRODUCT_NAME) to access your microphone", "recordAudioAndroid": true } ] ] } } ``` ## Usage ```tsx import { Camera } from '@/components/ui/camera'; ``` ```tsx { console.log('Captured:', uri, type); }} onVideoCapture={({ uri, type }) => { console.log('Video captured:', uri, type); }} onClose={() => { // Handle camera close }} /> ``` ## Examples #### Default **Example:** A basic camera with default settings ```tsx // components/demo/camera/camera-demo.tsx import { Camera } from '@/components/ui/camera'; import React from 'react'; import { Alert } from 'react-native'; export function CameraDemo() { const handleCapture = ({ uri, type }: { uri: string; type: string }) => { Alert.alert('Picture Captured', `Saved to: ${uri}`); }; const handleVideoCapture = ({ uri, type }: { uri: string; type: string }) => { Alert.alert('Video Recorded', `Saved to: ${uri}`); }; return ( ); } ``` #### With Custom Controls **Example:** Camera with custom control settings ```tsx // components/demo/camera/camera-custom-controls.tsx import { Camera } from '@/components/ui/camera'; import React from 'react'; import { Alert } from 'react-native'; export function CameraCustomControls() { const handleCapture = ({ uri, type }: { uri: string; type: string }) => { Alert.alert('Capture Complete', `${type} saved successfully`); }; const handleVideoCapture = ({ uri, type }: { uri: string; type: string }) => { Alert.alert('Recording Complete', `Video saved successfully`); }; return ( ); } ``` #### Picture Only Mode **Example:** Camera configured for picture-only mode ```tsx // components/demo/camera/camera-picture-only.tsx import { Camera } from '@/components/ui/camera'; import React from 'react'; import { Alert } from 'react-native'; export function CameraPictureOnly() { const handleCapture = ({ uri, type }: { uri: string; type: string }) => { Alert.alert('Photo Captured', 'Picture saved to gallery'); }; return ( ); } ``` #### Video Recording **Example:** Camera with video recording capabilities ```tsx // components/demo/camera/camera-video.tsx import { Camera } from '@/components/ui/camera'; import React from 'react'; import { Alert } from 'react-native'; export function CameraVideo() { const handleCapture = ({ uri, type }: { uri: string; type: string }) => { Alert.alert( 'Picture Taken', `Saved: ${uri.substring(uri.lastIndexOf('/') + 1)}` ); }; const handleVideoCapture = ({ uri, type }: { uri: string; type: string }) => { Alert.alert('Video Recorded', `Duration: ${uri ? 'Success' : 'Failed'}`); }; return ( ); } ``` #### Timer Features **Example:** Camera with timer functionality ```tsx // components/demo/camera/camera-timer.tsx import { Camera } from '@/components/ui/camera'; import React from 'react'; import { Alert } from 'react-native'; export function CameraTimer() { const handleCapture = ({ uri, type }: { uri: string; type: string }) => { Alert.alert('Timer Capture', 'Photo captured after countdown!'); }; const handleVideoCapture = ({ uri, type }: { uri: string; type: string }) => { Alert.alert('Timer Recording', 'Video started after countdown!'); }; return ( ); } ``` #### Zoom Controls **Example:** Camera with zoom controls and gestures ```tsx // components/demo/camera/camera-zoom.tsx import { Camera } from '@/components/ui/camera'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; import { Alert } from 'react-native'; export function CameraZoom() { const handleCapture = ({ uri, type }: { uri: string; type: string }) => { Alert.alert('Zoomed Capture', 'Photo taken with current zoom level'); }; return ( Pinch to zoom • Double tap for quick zoom • Tap zoom button to cycle levels ); } ``` #### Settings Panel **Example:** Camera with advanced settings panel ```tsx // components/demo/camera/camera-settings.tsx import { Camera } from '@/components/ui/camera'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; import { Alert } from 'react-native'; export function CameraSettings() { const handleCapture = ({ uri, type }: { uri: string; type: string }) => { Alert.alert('Settings Demo', 'Captured with current settings applied'); }; const handleVideoCapture = ({ uri, type }: { uri: string; type: string }) => { Alert.alert('Settings Demo', 'Recorded with current settings applied'); }; return ( Tap the settings icon to access grid, sound, aspect ratio, and timer controls ); } ``` ## API Reference ### Camera The main camera component with comprehensive controls and features. | Prop | Type | Default | Description | | ------------------ | --------------------------------------- | ------------ | --------------------------------------------------- | | `style` | `ViewStyle` | - | Additional styles to apply to the camera container. | | `facing` | `'front' \| 'back'` | `'back'` | The initial camera facing direction. | | `enableTorch` | `boolean` | `true` | Whether to show the torch/flash control. | | `showControls` | `boolean` | `true` | Whether to show the camera controls overlay. | | `timerOptions` | `number[]` | `[0, 3, 10]` | Available timer options in seconds. | | `enableVideo` | `boolean` | `true` | Whether to enable video recording mode. | | `maxVideoDuration` | `number` | `60` | Maximum video duration in seconds. | | `onClose` | `() => void` | - | Callback when the close button is pressed. | | `onCapture` | `({ type, uri, cameraHeight }) => void` | - | Callback when a picture is captured. | | `onVideoCapture` | `({ type, uri, cameraHeight }) => void` | - | Callback when a video is captured. | ### CameraRef The camera component exposes these methods via ref: | Method | Type | Description | | ---------------- | --------------------- | ------------------------------------- | | `switchCamera` | `() => void` | Switch between front and back camera. | | `toggleTorch` | `() => void` | Toggle the torch/flash on and off. | | `takePicture` | `() => Promise` | Programmatically take a picture. | | `startRecording` | `() => Promise` | Start video recording. | | `stopRecording` | `() => Promise` | Stop video recording. | ### CaptureSuccess The callback data structure for successful captures: | Property | Type | Description | | -------------- | ---------------------- | -------------------------------------------- | | `type` | `'picture' \| 'video'` | The type of media captured. | | `uri` | `string` | The local URI of the captured media. | | `cameraHeight` | `number` | The height of the camera view when captured. | ## Features ### Camera Controls - **Capture Button**: Large, prominent button for taking pictures or starting/stopping video recording - **Mode Toggle**: Switch between picture and video modes - **Camera Flip**: Switch between front and back cameras - **Torch/Flash**: Toggle flashlight for back camera - **Zoom**: Pinch-to-zoom gestures and tap-to-zoom controls ### Advanced Features - **Timer**: Set delays of 0, 3, or 10 seconds before capture - **Grid Lines**: Rule of thirds overlay for better composition - **Aspect Ratios**: Support for 16:9, 4:3, and 1:1 ratios - **Sound Control**: Enable/disable camera sounds - **Settings Panel**: Collapsible panel with advanced options ### Gestures - **Pinch to Zoom**: Smooth zoom in/out with gesture controls - **Double Tap**: Quick zoom toggle between 1x and 2.5x - **Tap Controls**: Tap zoom button to cycle through zoom levels ### Video Recording - **Recording Timer**: Shows elapsed recording time - **Duration Limit**: Configurable maximum recording duration - **Visual Feedback**: Recording indicator with red dot animation ## Permissions The Camera component requires camera and microphone permissions. The component will: 1. Check for existing permissions 2. Show a permission request screen if not granted 3. Provide a clear call-to-action to grant permissions 4. Handle permission denial gracefully ## Accessibility The Camera component includes accessibility features: - **Screen Reader Support**: All controls have appropriate labels - **High Contrast**: Clear visual distinction between active/inactive states - **Touch Targets**: All interactive elements meet minimum size requirements - **Keyboard Navigation**: Focus management for external keyboard users - **Reduced Motion**: Respects system animation preferences ## Performance - **Optimized Rendering**: Minimal re-renders using React.memo and useCallback - **Gesture Handling**: Efficient native gesture recognition - **Memory Management**: Proper cleanup of timers and resources - **Battery Optimization**: Automatic torch disable when switching cameras ## Error Handling The component handles various error scenarios: - **Permission Denied**: Shows clear permission request screen - **Camera Unavailable**: Graceful fallback with error messages - **Recording Failures**: User-friendly error alerts - **Memory Issues**: Automatic cleanup and error recovery ## Customization The Camera component can be customized through: - **Theme Integration**: Uses theme colors and design tokens - **Custom Styles**: Style prop for container customization - **Control Visibility**: Toggle individual control elements - **Timer Options**: Configure available timer durations - **Aspect Ratios**: Support for multiple aspect ratios # Card > Displays a card with header, content, and footer. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/card - Markdown: https://ui.ahmedbna.com/docs/components/card.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/card.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/card.json - Install: `npx bna-ui add card` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `text`, `view` - Preview recording: https://demo.ahmedbna.com/0090-card-demo.PNG --- **Example:** A complete card with header, content, and footer sections ```tsx // components/demo/card/card-demo.tsx import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from '@/components/ui/card'; import { Text } from '@/components/ui/text'; import React from 'react'; export function CardDemo() { return ( Card Title This is a description of the card content. It provides additional context about what this card contains. This is the main content area of the card. You can put any content here including text, images, forms, or other components. ); } ``` ## Installation ### CLI ```bash npx bna-ui add card ``` ### Manual **1.** Copy and paste the following code into your project. ```tsx // components/ui/card.tsx import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { BORDER_RADIUS } from '@/theme/globals'; import { memo } from 'react'; import { TextProps as RNTextProps, TextStyle, ViewProps as RNViewProps, ViewStyle, } from 'react-native'; interface CardProps extends RNViewProps { children: React.ReactNode; style?: ViewStyle; } export const Card = memo(function Card({ children, style, ...props }: CardProps) { const cardColor = useColor('card'); const foregroundColor = useColor('foreground'); return ( {children} ); }); interface CardHeaderProps extends RNViewProps { children: React.ReactNode; style?: ViewStyle; } export const CardHeader = memo(function CardHeader({ children, style, ...props }: CardHeaderProps) { return ( {children} ); }); interface CardTitleProps extends RNTextProps { children: React.ReactNode; style?: TextStyle; } export const CardTitle = memo(function CardTitle({ children, style, ...props }: CardTitleProps) { return ( {children} ); }); interface CardDescriptionProps extends RNTextProps { children: React.ReactNode; style?: TextStyle; } export const CardDescription = memo(function CardDescription({ children, style, ...props }: CardDescriptionProps) { return ( {children} ); }); interface CardContentProps extends RNViewProps { children: React.ReactNode; style?: ViewStyle; } export const CardContent = memo(function CardContent({ children, style, ...props }: CardContentProps) { return ( {children} ); }); interface CardFooterProps extends RNViewProps { children: React.ReactNode; style?: ViewStyle; } export const CardFooter = memo(function CardFooter({ children, style, ...props }: CardFooterProps) { return ( {children} ); }); ``` **2.** Update the import paths to match your project setup. ## Usage ```tsx import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from '@/components/ui/card'; ``` ```tsx Card Title Card Description

Card Content

Card Footer

``` ## Examples #### Default **Example:** A complete card with header, content, and footer sections ```tsx // components/demo/card/card-demo.tsx import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from '@/components/ui/card'; import { Text } from '@/components/ui/text'; import React from 'react'; export function CardDemo() { return ( Card Title This is a description of the card content. It provides additional context about what this card contains. This is the main content area of the card. You can put any content here including text, images, forms, or other components. ); } ``` #### Simple **Example:** A minimal card with just content ```tsx // components/demo/card/card-simple.tsx import { Card, CardContent } from '@/components/ui/card'; import { Text } from '@/components/ui/text'; import React from 'react'; export function CardSimple() { return ( A simple card with just content. Perfect for displaying basic information or messages. ); } ``` #### With Image **Example:** Card featuring an image with content below ```tsx // components/demo/card/card-with-image.tsx import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from '@/components/ui/card'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; import { Image } from 'react-native'; export function CardWithImage() { return ( Beautiful Landscape A stunning view captured in the mountains during golden hour. This image showcases the beauty of nature with its vibrant colors and serene atmosphere. ); } ``` #### With Form **Example:** Interactive card containing a login form ```tsx // components/demo/card/card-with-form.tsx import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from '@/components/ui/card'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import React, { useState } from 'react'; import { TextInput } from 'react-native'; export function CardWithForm() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const borderColor = useColor('border'); const backgroundColor = useColor('background'); const textColor = useColor('text'); return ( Sign In Enter your credentials to access your account. Email Password ); } ``` #### Statistics **Example:** Grid of cards displaying key metrics and statistics ```tsx // components/demo/card/card-stats.tsx import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function CardStats() { const stats = [ { title: 'Total Users', value: '12,543', change: '+12%' }, { title: 'Revenue', value: '$45,231', change: '+8%' }, { title: 'Orders', value: '1,234', change: '+23%' }, { title: 'Growth', value: '15.3%', change: '+4%' }, ]; return ( {stats.map((stat, index) => ( {stat.title} {stat.value} {stat.change} from last month ))} ); } ``` #### Notification **Example:** Card designed for displaying notifications with actions ```tsx // components/demo/card/card-notification.tsx import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from '@/components/ui/card'; import { Icon } from '@/components/ui/icon'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { Bell } from 'lucide-react-native'; import React from 'react'; export function CardNotification() { return ( New Notification 2 minutes ago You have a new message from John Doe. Click to view the full conversation and respond. ); } ``` #### Pricing **Example:** Professional pricing cards with feature lists and CTAs ```tsx // components/demo/card/card-pricing.tsx import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from '@/components/ui/card'; import { Icon } from '@/components/ui/icon'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { Check } from 'lucide-react-native'; import React from 'react'; export function CardPricing() { const plans = [ { name: 'Basic', price: '$9', description: 'Perfect for individuals', features: ['1 Project', '5GB Storage', 'Email Support'], popular: false, }, { name: 'Pro', price: '$29', description: 'Best for small teams', features: [ '10 Projects', '100GB Storage', // 'Priority Support', // 'Advanced Analytics', ], popular: true, }, { name: 'Enterprise', price: '$99', description: 'For large organizations', features: [ 'Unlimited Projects', '1TB Storage', // '24/7 Support', // 'Custom Integrations', ], popular: false, }, ]; return ( {plans.map((plan, index) => ( {plan.popular && ( POPULAR )} {plan.name} {plan.description} {plan.price} /month {plan.features.map((feature, featureIndex) => ( {feature} ))} {/* */} ))} ); } ``` ## API Reference ### Card The main container component that wraps all card content. | Prop | Type | Description | | ---------- | ----------- | --------------------------------------------------- | | `children` | `ReactNode` | The card content (typically header/content/footer). | | `style` | `ViewStyle` | Additional styles to apply to the card container. | ### CardHeader Container for the card's header content, typically containing title and description. | Prop | Type | Description | | ---------- | ----------- | --------------------------------------------------------- | | `children` | `ReactNode` | The header content (typically CardTitle/CardDescription). | | `style` | `ViewStyle` | Additional styles to apply to the header container. | ### CardTitle The main title text of the card. | Prop | Type | Description | | ---------- | ----------- | --------------------------------------------- | | `children` | `ReactNode` | The title text content. | | `style` | `TextStyle` | Additional styles to apply to the title text. | ### CardDescription Subtitle or description text for the card. | Prop | Type | Description | | ---------- | ----------- | --------------------------------------------------- | | `children` | `ReactNode` | The description text content. | | `style` | `TextStyle` | Additional styles to apply to the description text. | ### CardContent Container for the main content of the card. | Prop | Type | Description | | ---------- | ----------- | ---------------------------------------------------- | | `children` | `ReactNode` | The main content of the card. | | `style` | `ViewStyle` | Additional styles to apply to the content container. | ### CardFooter Container for the card's footer content, typically containing actions or additional info. | Prop | Type | Description | | ---------- | ----------- | --------------------------------------------------- | | `children` | `ReactNode` | The footer content (typically buttons or actions). | | `style` | `ViewStyle` | Additional styles to apply to the footer container. | ## Design Guidelines ### Visual Hierarchy Cards help establish clear visual hierarchy in your interface: - Use **CardTitle** for the primary heading (larger, bolder text) - Use **CardDescription** for supporting information (smaller, muted text) - Organize content logically from header to content to footer ### Layout Patterns Cards work well in various layout patterns: - **Single cards** for focused content or forms - **Card grids** for displaying multiple related items - **Card lists** for sequential content like notifications - **Dashboard cards** for metrics and statistics ### Content Guidelines - Keep titles concise and descriptive - Use descriptions to provide context without overwhelming - Place primary actions in the footer - Ensure adequate spacing between sections ### Accessibility The Card component follows accessibility best practices: - Semantic structure for screen readers - Proper text hierarchy with heading roles - Adequate contrast ratios for all text - Touch-friendly interactive elements - Keyboard navigation support ### Responsive Design Cards automatically adapt to different screen sizes: - Flexible width that responds to container constraints - Proper text scaling on different devices - Consistent spacing across all screen sizes - Touch-optimized interactive elements # Carousel > A flexible carousel component with support for auto-play, indicators, arrows, and custom layouts. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/carousel - Markdown: https://ui.ahmedbna.com/docs/components/carousel.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/carousel.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/carousel.json - Install: `npx bna-ui add carousel` - npm dependencies: `expo-blur`, `lucide-react-native`, `react-native-gesture-handler` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `view` - Preview recording: https://demo.ahmedbna.com/0097-carousel-demo.MP4 --- **Example:** A basic carousel with auto-play and indicators ```tsx // components/demo/carousel/carousel-demo.tsx import { Carousel, CarouselItem } from '@/components/ui/carousel'; import { Text } from '@/components/ui/text'; export function CarouselDemo() { return ( {slides.map((slide) => ( {slide.title} {slide.content} ))} ); } const slides = [ { id: 1, title: 'Full Width Slide 1', content: 'This slide takes the full width of the container', }, { id: 2, title: 'Full Width Slide 2', content: 'Perfect for hero sections and main content', }, { id: 3, title: 'Full Width Slide 3', content: 'Uses paging for smooth navigation', }, { id: 4, title: 'Full Width Slide 4', content: 'Default behavior - no spacing needed', }, ]; ``` ## Installation ### CLI ```bash npx bna-ui add carousel ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install expo-blur lucide-react-native react-native-gesture-handler ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/carousel.tsx import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { BORDER_RADIUS } from '@/theme/globals'; import { BlurView } from 'expo-blur'; import { ChevronLeft, ChevronRight } from 'lucide-react-native'; import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, } from 'react'; import { NativeScrollEvent, NativeSyntheticEvent, ScrollView, TouchableOpacity, useWindowDimensions, ViewStyle, } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; interface CarouselProps { children: React.ReactNode[]; autoPlay?: boolean; autoPlayInterval?: number; showIndicators?: boolean; showArrows?: boolean; loop?: boolean; itemWidth?: number; spacing?: number; style?: ViewStyle; onIndexChange?: (index: number) => void; } interface CarouselItemProps { children: React.ReactNode; style?: ViewStyle[] | ViewStyle; } interface CarouselContentProps { children: React.ReactNode; style?: ViewStyle; } interface CarouselIndicatorsProps { total: number; current: number; onPress?: (index: number) => void; style?: ViewStyle; } interface CarouselArrowProps { direction: 'left' | 'right'; onPress: () => void; disabled?: boolean; style?: ViewStyle; } // Define the ref interface export interface CarouselRef { goToSlide: (index: number) => void; goToNext: () => void; goToPrevious: () => void; getCurrentIndex: () => number; } // Main Carousel Component export const Carousel = forwardRef( ( { children, autoPlay = false, autoPlayInterval = 3000, showIndicators = true, showArrows = false, loop = false, itemWidth, spacing = 0, style, onIndexChange, }, ref ) => { const { width: screenWidth } = useWindowDimensions(); const scrollViewRef = useRef(null); const [currentIndex, setCurrentIndex] = useState(0); const [containerWidth, setContainerWidth] = useState(screenWidth); const [isUserInteracting, setIsUserInteracting] = useState(false); // Use useRef to store timer ID and prevent stale closures const autoPlayTimerRef = useRef | null>(null); const scrollTimeoutRef = useRef | null>(null); const currentIndexRef = useRef(currentIndex); // Keep ref in sync for auto play // Update ref when currentIndex changes useEffect(() => { currentIndexRef.current = currentIndex; }, [currentIndex]); // Calculate slide dimensions const slideWidth = itemWidth || containerWidth - spacing * 2; const snapToInterval = slideWidth + spacing; // Clear all timers const clearTimers = useCallback(() => { if (autoPlayTimerRef.current) { clearInterval(autoPlayTimerRef.current); autoPlayTimerRef.current = null; } if (scrollTimeoutRef.current) { clearTimeout(scrollTimeoutRef.current); scrollTimeoutRef.current = null; } }, []); // Scroll to current index const scrollToIndex = useCallback( (index: number, animated: boolean = true) => { if (scrollViewRef.current && index >= 0 && index < children.length) { const scrollX = index * snapToInterval; // Use requestAnimationFrame to ensure smooth scrolling requestAnimationFrame(() => { if (scrollViewRef.current) { scrollViewRef.current.scrollTo({ x: scrollX, animated, }); } }); } }, [snapToInterval, children.length] ); // Navigation functions const goToSlide = useCallback( (index: number) => { if (index >= 0 && index < children.length && index !== currentIndex) { setCurrentIndex(index); setIsUserInteracting(true); scrollToIndex(index); // Clear auto play timeout to prevent conflicts if (scrollTimeoutRef.current) { clearTimeout(scrollTimeoutRef.current); scrollTimeoutRef.current = null; } } }, [children.length, scrollToIndex, currentIndex] ); const goToNext = useCallback(() => { const nextIndex = currentIndexRef.current + 1; const targetIndex = nextIndex < children.length ? nextIndex : loop ? 0 : currentIndexRef.current; if (targetIndex !== currentIndexRef.current) { setCurrentIndex(targetIndex); setIsUserInteracting(true); scrollToIndex(targetIndex); // Clear auto play timeout to prevent conflicts if (scrollTimeoutRef.current) { clearTimeout(scrollTimeoutRef.current); scrollTimeoutRef.current = null; } } }, [children.length, loop, scrollToIndex]); const goToPrevious = useCallback(() => { const prevIndex = currentIndexRef.current - 1; const targetIndex = prevIndex >= 0 ? prevIndex : loop ? children.length - 1 : currentIndexRef.current; if (targetIndex !== currentIndexRef.current) { setCurrentIndex(targetIndex); setIsUserInteracting(true); scrollToIndex(targetIndex); // Clear auto play timeout to prevent conflicts if (scrollTimeoutRef.current) { clearTimeout(scrollTimeoutRef.current); scrollTimeoutRef.current = null; } } }, [loop, children.length, scrollToIndex]); // Expose methods through ref useImperativeHandle( ref, () => ({ goToSlide, goToNext, goToPrevious, getCurrentIndex: () => currentIndex, }), [goToSlide, goToNext, goToPrevious, currentIndex] ); // Start auto play - Fixed to actually scroll the view const startAutoPlay = useCallback(() => { if (!autoPlay || children.length <= 1 || isUserInteracting) return; clearTimers(); autoPlayTimerRef.current = setInterval(() => { const nextIndex = currentIndexRef.current + 1; const targetIndex = nextIndex >= children.length ? loop ? 0 : currentIndexRef.current : nextIndex; if (targetIndex !== currentIndexRef.current) { // Update state and scroll to new position setCurrentIndex(targetIndex); scrollToIndex(targetIndex, true); } }, autoPlayInterval); }, [ autoPlay, autoPlayInterval, children.length, loop, isUserInteracting, clearTimers, scrollToIndex, ]); // Stop auto play const stopAutoPlay = useCallback(() => { clearTimers(); }, [clearTimers]); // Handle auto play lifecycle useEffect(() => { if (autoPlay && !isUserInteracting) { startAutoPlay(); } else { stopAutoPlay(); } return stopAutoPlay; }, [autoPlay, isUserInteracting, startAutoPlay, stopAutoPlay]); // Handle index changes - notify parent component with debouncing useEffect(() => { // Use a small delay to prevent rapid-fire updates during navigation const timeoutId = setTimeout(() => { onIndexChange?.(currentIndex); }, 50); return () => clearTimeout(timeoutId); }, [currentIndex, onIndexChange]); // Handle scroll events - only update index from user scrolling const handleScroll = useCallback( (event: NativeSyntheticEvent) => { // Only update index from scroll if user is manually scrolling if (isUserInteracting) { const scrollPosition = event.nativeEvent.contentOffset.x; const index = Math.round(scrollPosition / snapToInterval); if (index !== currentIndex && index >= 0 && index < children.length) { setCurrentIndex(index); } } }, [currentIndex, snapToInterval, children.length, isUserInteracting] ); // Handle momentum scroll end const handleMomentumScrollEnd = useCallback( (event: NativeSyntheticEvent) => { const scrollPosition = event.nativeEvent.contentOffset.x; const index = Math.round(scrollPosition / snapToInterval); // Update index based on final scroll position if (index >= 0 && index < children.length && index !== currentIndex) { setCurrentIndex(index); } // Re-enable auto play after user interaction ends if (autoPlay) { scrollTimeoutRef.current = setTimeout(() => { setIsUserInteracting(false); }, 1000); } }, [snapToInterval, children.length, autoPlay, currentIndex] ); // Touch handlers const handleTouchStart = useCallback(() => { setIsUserInteracting(true); }, []); const handleTouchEnd = useCallback(() => { // Don't immediately re-enable auto play, let momentum scroll end handle it }, []); // Cleanup on unmount useEffect(() => { return () => { clearTimers(); }; }, [clearTimers]); const horizontalPan = Gesture.Pan() .onBegin(() => { // Optional: trigger when gesture starts }) .onUpdate(() => { // Optional: you can track gesture updates here }) .onEnd(() => { // Optional: trigger when gesture ends }) .activeOffsetX([-10, 10]) // Allow horizontal pan .activeOffsetY([-1000, 1000]); // Block vertical gesture return ( { const { width } = event.nativeEvent.layout; // Ensure we have a valid width if (width > 0) { setContainerWidth(width); } }} > {children.map((child, index) => ( {child} ))} {showArrows && children.length > 1 && ( <> )} {showIndicators && children.length > 1 && ( )} ); } ); // Carousel Content Component export function CarouselContent({ children, style }: CarouselContentProps) { return {children}; } // Carousel Item Component - Auto height to fit content export function CarouselItem({ children, style }: CarouselItemProps) { const backgroundColor = useColor('card'); const borderColor = useColor('border'); return ( {children} ); } // Carousel Indicators Component export function CarouselIndicators({ total, current, onPress, style, }: CarouselIndicatorsProps) { const primaryColor = useColor('primary'); const secondaryColor = useColor('secondary'); return ( {Array.from({ length: total }, (_, index) => ( onPress?.(index)} style={{ width: 8, height: 8, borderRadius: 999, backgroundColor: index === current ? primaryColor : secondaryColor, }} hitSlop={{ top: 12, bottom: 12, left: 8, right: 8 }} accessibilityRole='button' accessibilityLabel={`Go to slide ${index + 1} of ${total}`} accessibilityState={{ selected: index === current }} /> ))} ); } // Carousel Arrow Component export function CarouselArrow({ direction, onPress, disabled = false, style, }: CarouselArrowProps) { const primaryColor = useColor('primary'); return ( {direction === 'left' ? ( ) : ( )} ); } ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { Carousel, CarouselContent, CarouselItem, } from '@/components/ui/carousel'; ``` ```tsx Slide 1 Slide 2 Slide 3 ``` ## Examples #### Default **Example:** A basic carousel with auto-play and indicators ```tsx // components/demo/carousel/carousel-demo.tsx import { Carousel, CarouselItem } from '@/components/ui/carousel'; import { Text } from '@/components/ui/text'; export function CarouselDemo() { return ( {slides.map((slide) => ( {slide.title} {slide.content} ))} ); } const slides = [ { id: 1, title: 'Full Width Slide 1', content: 'This slide takes the full width of the container', }, { id: 2, title: 'Full Width Slide 2', content: 'Perfect for hero sections and main content', }, { id: 3, title: 'Full Width Slide 3', content: 'Uses paging for smooth navigation', }, { id: 4, title: 'Full Width Slide 4', content: 'Default behavior - no spacing needed', }, ]; ``` #### With Arrows **Example:** Carousel with navigation arrows and indicators ```tsx // components/demo/carousel/carousel-arrows.tsx import { Carousel, CarouselItem } from '@/components/ui/carousel'; import { Icon } from '@/components/ui/icon'; import { Text } from '@/components/ui/text'; import { Award, Heart, Star, Zap } from 'lucide-react-native'; export function CarouselArrows() { const slides = [ { icon: Heart, title: 'Loved by Users', description: 'Thousands of happy customers worldwide trust our products.', color: '#fee2e2', bg: '#FF6B6B', }, { icon: Zap, title: 'Lightning Fast', description: 'Optimized for performance with smooth animations.', color: '#f3e8ff', bg: '#8b5cf6', }, { icon: Star, title: 'Premium Quality', description: 'Built with the highest standards and attention to detail.', color: '#f59e0b', bg: '#fef3c7', }, { icon: Award, title: 'Award Winning', description: 'Recognized for excellence in design and functionality.', color: '#d1fae5', bg: '#4ECDC4', }, ]; return ( {slides.map((slide, index) => { const name = slide.icon; return ( {slide.title} {slide.description} ); })} ); } ``` #### Custom Item Width **Example:** Carousel with custom item width and spacing ```tsx // components/demo/carousel/carousel-custom-width.tsx import { Carousel, CarouselItem } from '@/components/ui/carousel'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import React from 'react'; import { Dimensions } from 'react-native'; const { width: screenWidth } = Dimensions.get('window'); export function CarouselCustomWidth() { const cardColor = useColor('card'); const textColor = useColor('text'); const products = [ { id: 1, name: 'Wireless Headphones', price: '$99.99', rating: '4.8' }, { id: 2, name: 'Smart Watch', price: '$199.99', rating: '4.9' }, { id: 3, name: 'Bluetooth Speaker', price: '$79.99', rating: '4.7' }, { id: 4, name: 'Phone Case', price: '$24.99', rating: '4.6' }, { id: 5, name: 'Wireless Charger', price: '$39.99', rating: '4.8' }, ]; return ( {products.map((product) => ( {product.name} ⭐ {product.rating} rating {product.price} ))} Swipe to see more products → ); } ``` #### Image Carousel **Example:** Image carousel with auto-play and loop ```tsx // components/demo/carousel/carousel-images.tsx import { Carousel, CarouselItem } from '@/components/ui/carousel'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { BORDER_RADIUS } from '@/theme/globals'; import { Image } from 'expo-image'; import React from 'react'; export function CarouselImages() { const images = [ { uri: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=400&h=300&fit=crop', title: 'Mountain Landscape', description: 'Breathtaking mountain views', }, { uri: 'https://images.unsplash.com/photo-1439066615861-d1af74d74000?w=400&h=300&fit=crop', title: 'Forest Path', description: 'Peaceful forest walking trail', }, { uri: 'https://images.unsplash.com/photo-1501436513145-30f24e19fcc4?w=400&h=300&fit=crop', title: 'Ocean Sunset', description: 'Golden hour by the sea', }, { uri: 'https://images.unsplash.com/photo-1500375592092-40eb2168fd21?w=400&h=300&fit=crop', title: 'Desert Dunes', description: 'Vast desert landscape', }, ]; return ( {images.map((image, index) => ( {image.title} {image.description} ))} ); } ``` #### Card Carousel **Example:** Card-based carousel with custom content ```tsx // components/demo/carousel/carousel-cards.tsx import { Carousel, CarouselItem } from '@/components/ui/carousel'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { Calendar, MapPin, User } from 'lucide-react-native'; import React from 'react'; export function CarouselCards() { const cardColor = useColor('card'); const textColor = useColor('text'); const primaryColor = useColor('primary'); const events = [ { title: 'Tech Summit', location: 'New York, NY', date: 'Apr 8-10, 2024', attendees: 2100, category: 'Technology', color: '#10b981', }, { title: 'Creative Workshop', location: 'Los Angeles, CA', date: 'May 22-23, 2024', attendees: 850, category: 'Creative', color: '#f59e0b', }, { title: 'Design Conference 2024', location: 'San Francisco, CA', date: 'Mar 15-17, 2024', attendees: 1250, category: 'Design', color: '#3b82f6', }, ]; return ( {events.map((event, index) => ( {/* Category Badge */} {event.category} {/* Event Title */} {event.title} {/* Event Details */} {event.location} {event.date} {event.attendees.toLocaleString()} attendees {/* Action Button */} Learn More ))} ); } ``` #### No Indicators **Example:** Carousel without indicators, arrows only ```tsx // components/demo/carousel/carousel-no-indicators.tsx import { Carousel, CarouselItem } from '@/components/ui/carousel'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { Minus, TrendingDown, TrendingUp } from 'lucide-react-native'; import React from 'react'; export function CarouselNoIndicators() { const textColor = useColor('text'); const stats = [ { title: 'Revenue', value: '$24,500', change: '+12.5%', trend: 'up', color: '#10b981', }, { title: 'Users', value: '8,432', change: '+5.2%', trend: 'up', color: '#3b82f6', }, { title: 'Orders', value: '1,248', change: '-2.1%', trend: 'down', color: '#ef4444', }, { title: 'Conversion', value: '3.8%', change: '0.0%', trend: 'neutral', color: '#6b7280', }, ]; const getTrendIcon = (trend: string) => { switch (trend) { case 'up': return TrendingUp; case 'down': return TrendingDown; default: return Minus; } }; return ( {stats.map((stat, index) => { const TrendIcon = getTrendIcon(stat.trend); return ( {stat.title} {stat.value} {stat.change} ); })} ); } ``` #### Manual Control **Example:** Manually controlled carousel with external buttons ```tsx // components/demo/carousel/carousel-manual.tsx import { Button } from '@/components/ui/button'; import { Carousel, CarouselItem, CarouselRef } from '@/components/ui/carousel'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { ChevronLeft, ChevronRight, RotateCcw } from 'lucide-react-native'; import React, { useRef, useState } from 'react'; export function CarouselManual() { const totalSlides = 4; const carouselRef = useRef(null); const [currentIndex, setCurrentIndex] = useState(0); const lessons = [ { title: 'Introduction to React Native', progress: 0, duration: '15 min', bg: '#9c27b0', color: '#f3e5f5', }, { title: 'Component Architecture', progress: 45, duration: '20 min', bg: '#ff5722', color: '#fff3e0', }, { title: 'State Management', progress: 75, duration: '25 min', bg: '#00bcd4', color: '#e0f2f1', }, { title: 'Navigation Patterns', progress: 100, duration: '18 min', bg: '#ff4081', color: '#fce4ec', }, ]; const handleGoToSlide = (index: number) => { if (carouselRef.current?.goToSlide) { carouselRef.current.goToSlide(index); } }; const handleGoToPrevious = () => { if (currentIndex > 0) { if (carouselRef.current?.goToPrevious) { carouselRef.current.goToPrevious(); } } }; const handleGoToNext = () => { if (currentIndex < totalSlides - 1) { if (carouselRef.current?.goToNext) { carouselRef.current.goToNext(); } } }; const handleReset = () => { if (carouselRef.current?.goToSlide) { carouselRef.current.goToSlide(0); } }; // Handle index changes from carousel (swipe gestures AND button presses) const handleIndexChange = (index: number) => { setCurrentIndex(index); }; return ( {/* External Controls */} ))} ); } ``` ## API Reference ### Carousel The main carousel container component. | Prop | Type | Default | Description | | ------------------ | ------------------------- | ------- | ---------------------------------------------------------- | | `children` | `ReactNode[]` | - | Array of carousel items to display. | | `autoPlay` | `boolean` | `false` | Enable automatic slide progression. | | `autoPlayInterval` | `number` | `3000` | Interval between auto-play slides in milliseconds. | | `showIndicators` | `boolean` | `true` | Show dot indicators at the bottom. | | `showArrows` | `boolean` | `false` | Show navigation arrows on the sides. | | `loop` | `boolean` | `false` | Enable infinite loop mode. | | `itemWidth` | `number` | - | Custom width for carousel items (enables multi-item view). | | `spacing` | `number` | `0` | Spacing between carousel items. | | `style` | `ViewStyle` | - | Additional styles for the carousel container. | | `onIndexChange` | `(index: number) => void` | - | Callback fired when the active slide changes. | ### CarouselContent A wrapper component for organizing carousel content. | Prop | Type | Default | Description | | ---------- | ----------- | ------- | -------------------------------------------- | | `children` | `ReactNode` | - | The content to display inside the carousel. | | `style` | `ViewStyle` | - | Additional styles for the content container. | ### CarouselItem Individual carousel slide component with default styling. | Prop | Type | Default | Description | | ---------- | -------------------------- | ------- | ----------------------------------------- | | `children` | `ReactNode` | - | The content to display in the slide. | | `style` | `ViewStyle \| ViewStyle[]` | - | Additional styles for the item container. | ### CarouselIndicators Dot indicators component for showing current slide position. | Prop | Type | Default | Description | | --------- | ------------------------- | ------- | ----------------------------------------------- | | `total` | `number` | - | Total number of slides. | | `current` | `number` | - | Current active slide index. | | `onPress` | `(index: number) => void` | - | Callback when an indicator is pressed. | | `style` | `ViewStyle` | - | Additional styles for the indicators container. | ### CarouselArrow Navigation arrow component for manual slide control. | Prop | Type | Default | Description | | ----------- | ------------------- | ------- | ------------------------------------------ | | `direction` | `'left' \| 'right'` | - | Arrow direction. | | `onPress` | `() => void` | - | Callback when the arrow is pressed. | | `disabled` | `boolean` | `false` | Whether the arrow is disabled. | | `style` | `ViewStyle` | - | Additional styles for the arrow container. | ### Custom Styling ```tsx {/* Your content */} ``` ## Features - **Auto-play**: Automatic slide progression with customizable intervals - **Touch Gestures**: Swipe to navigate between slides using react-native-gesture-handler - **Indicators**: Dot indicators showing current position with tap-to-navigate - **Navigation Arrows**: Optional left/right arrow controls with blur effects - **Loop Mode**: Infinite scrolling capability for seamless navigation - **Custom Layouts**: Support for custom item widths and spacing for multi-item views - **Responsive**: Adapts to container width automatically - **Smooth Animations**: Hardware-accelerated scrolling with momentum and spring physics - **Accessibility**: Built-in accessibility support with proper ARIA labels ## Accessibility The Carousel component includes accessibility features: - Dot indicators expose `accessibilityLabel` (e.g. "Go to slide 2 of 5") and `accessibilityState={{ selected }}` - Arrow buttons are labeled "Previous slide"/"Next slide" and report their disabled state ## Performance Tips - Use `itemWidth` prop for multi-item carousels to optimize rendering - Implement lazy loading for image carousels with many slides - Consider disabling auto-play for carousels with heavy content - Use `onIndexChange` callback to preload adjacent slides for smoother navigation # Checkbox > A control that allows the user to toggle between checked and not checked states. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/checkbox - Markdown: https://ui.ahmedbna.com/docs/components/checkbox.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/checkbox.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/checkbox.json - Install: `npx bna-ui add checkbox` - npm dependencies: `expo-haptics`, `lucide-react-native` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `useHaptics`, `globals`, `text`, `view` - Preview recording: https://demo.ahmedbna.com/0104-checkbox-demo.MP4 --- **Example:** A basic checkbox with label ```tsx // components/demo/checkbox/checkbox-demo.tsx import { Checkbox } from '@/components/ui/checkbox'; import React, { useState } from 'react'; export function CheckboxDemo() { const [checked, setChecked] = useState(false); return ( ); } ``` ## Installation ### CLI ```bash npx bna-ui add checkbox ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install lucide-react-native ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/checkbox.tsx import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { useHaptics } from '@/hooks/useHaptics'; import { BORDER_RADIUS } from '@/theme/globals'; import { Check } from 'lucide-react-native'; import React from 'react'; import { TextStyle, TouchableOpacity } from 'react-native'; interface CheckboxProps { checked: boolean; label?: string; error?: string; disabled?: boolean; labelStyle?: TextStyle; onCheckedChange: (checked: boolean) => void; accessibilityLabel?: string; haptic?: boolean; } export function Checkbox({ checked, error, disabled = false, label, labelStyle, onCheckedChange, accessibilityLabel, haptic = true, }: CheckboxProps) { const primary = useColor('primary'); const primaryForegroundColor = useColor('primaryForeground'); const danger = useColor('red'); const borderColor = useColor('border'); const feedback = useHaptics(haptic); const handlePress = () => { if (disabled) return; feedback(checked ? 'toggle-off' : 'toggle-on'); onCheckedChange(!checked); }; return ( {checked && ( )} {label && ( {label} )} ); } ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { Checkbox } from '@/components/ui/checkbox'; ``` ```tsx const [checked, setChecked] = useState(false); ; ``` ## Examples #### Default **Example:** A basic checkbox with label ```tsx // components/demo/checkbox/checkbox-demo.tsx import { Checkbox } from '@/components/ui/checkbox'; import React, { useState } from 'react'; export function CheckboxDemo() { const [checked, setChecked] = useState(false); return ( ); } ``` #### Different States **Example:** Checkboxes in different states: unchecked, checked, and disabled ```tsx // components/demo/checkbox/checkbox-states.tsx import { Checkbox } from '@/components/ui/checkbox'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function CheckboxStates() { const [checked1, setChecked1] = useState(false); const [checked2, setChecked2] = useState(true); const [checked3, setChecked3] = useState(false); return ( Different States ); } ``` #### Without Label **Example:** A checkbox without a label ```tsx // components/demo/checkbox/checkbox-without-label.tsx import { Checkbox } from '@/components/ui/checkbox'; import React, { useState } from 'react'; export function CheckboxWithoutLabel() { const [checked, setChecked] = useState(false); return ; } ``` #### With Error State **Example:** A checkbox with error styling and message ```tsx // components/demo/checkbox/checkbox-with-error.tsx import { Checkbox } from '@/components/ui/checkbox'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function CheckboxWithError() { const [checked, setChecked] = useState(false); return ( {!checked && ( You must accept the terms to continue )} ); } ``` #### Custom Styling **Example:** Checkboxes with custom label styling ```tsx // components/demo/checkbox/checkbox-custom-styling.tsx import { Checkbox } from '@/components/ui/checkbox'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function CheckboxCustomStyling() { const [checked1, setChecked1] = useState(false); const [checked2, setChecked2] = useState(true); return ( ); } ``` #### Checkbox Group **Example:** Multiple checkboxes working together as a group ```tsx // components/demo/checkbox/checkbox-group.tsx import { Checkbox } from '@/components/ui/checkbox'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function CheckboxGroup() { const [selectedItems, setSelectedItems] = useState([]); const items = [ { id: 'notifications', label: 'Email notifications' }, { id: 'marketing', label: 'Marketing emails' }, { id: 'updates', label: 'Product updates' }, { id: 'newsletter', label: 'Weekly newsletter' }, ]; const handleItemChange = (itemId: string, checked: boolean) => { if (checked) { setSelectedItems((prev) => [...prev, itemId]); } else { setSelectedItems((prev) => prev.filter((id) => id !== itemId)); } }; return ( Subscription Preferences {items.map((item) => ( handleItemChange(item.id, checked)} label={item.label} /> ))} Selected: {selectedItems.length} item(s) ); } ``` ## API Reference ### Checkbox A checkbox component that allows users to select or deselect an option. Uses `checked`/`onCheckedChange` (ARIA checkbox semantics) rather than `radio`'s `value`/`onValueChange` or `toggle`'s `pressed`/`onPressedChange` — each naming convention matches its own control's interaction model and is intentional, not an inconsistency. | Prop | Type | Default | Description | | -------------------- | ---------------------------- | ------- | ------------------------------------------------------------------------- | | `haptic` | `boolean` | `true` | Whether to trigger haptic feedback when the checkbox is toggled. | | `checked` | `boolean` | - | Whether the checkbox is checked. | | `onCheckedChange` | `(checked: boolean) => void` | - | Callback function called when the checked state changes. | | `label` | `string` | - | Optional label text to display next to the checkbox. | | `error` | `string` | - | Error message to display (affects styling). | | `disabled` | `boolean` | `false` | Whether the checkbox is disabled. | | `labelStyle` | `TextStyle` | - | Additional styles to apply to the label text. | | `accessibilityLabel` | `string` | - | Accessibility label for screen readers. Defaults to `label` when omitted. | ## Accessibility The Checkbox component is built with accessibility in mind: - Uses TouchableOpacity for proper touch handling - Supports disabled state with reduced opacity - Label text is properly associated with the checkbox - Provides visual feedback for checked/unchecked states - Uses semantic color theming for different states - Supports custom styling while maintaining accessibility ## Theming The component uses the following theme colors: - `primary`: Color for checked state border and background - `primaryForeground`: Color for the check icon - `border`: Color for unchecked state border - `red`: Color for error state styling These colors are automatically resolved using the `useColor` hook to support light and dark themes. # Collapsible > An interactive component which can be expanded/collapsed to show and hide content. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/collapsible - Markdown: https://ui.ahmedbna.com/docs/components/collapsible.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/collapsible.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/collapsible.json - Install: `npx bna-ui add collapsible` - npm dependencies: `expo-haptics`, `lucide-react-native`, `react-native-svg` - Registry dependencies: `useHaptics`, `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `text`, `view`, `icon` - Preview recording: https://demo.ahmedbna.com/0110-collapsible-demo.MP4 --- **Example:** A basic collapsible component with title and content ```tsx // components/demo/collapsible/collapsible-demo.tsx import { Collapsible } from '@/components/ui/collapsible'; import { Text } from '@/components/ui/text'; import React from 'react'; export function CollapsibleDemo() { return ( React Native is an open-source UI software framework created by Meta. It is used to develop applications for Android, iOS, macOS, tvOS, Web, Windows and UWP by enabling developers to use React's framework along with native platform capabilities. ); } ``` ## Installation ### CLI ```bash npx bna-ui add collapsible ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install lucide-react-native ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/collapsible.tsx import { Icon } from '@/components/ui/icon'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useHaptics } from '@/hooks/useHaptics'; import { ChevronRight } from 'lucide-react-native'; import { PropsWithChildren, useState } from 'react'; import { TouchableOpacity } from 'react-native'; export function Collapsible({ children, title, haptic = true, }: PropsWithChildren & { title: string; haptic?: boolean }) { const [isOpen, setIsOpen] = useState(false); const feedback = useHaptics(haptic); const handlePress = () => { feedback(isOpen ? 'toggle-off' : 'toggle-on'); setIsOpen((value) => !value); }; return ( {title} {isOpen && ( {children} )} ); } ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { Collapsible } from '@/components/ui/collapsible'; ``` ```tsx React Native is an open-source UI software framework created by Meta. It is used to develop applications for multiple platforms. ``` ## Examples #### Default **Example:** A basic collapsible component with title and content ```tsx // components/demo/collapsible/collapsible-demo.tsx import { Collapsible } from '@/components/ui/collapsible'; import { Text } from '@/components/ui/text'; import React from 'react'; export function CollapsibleDemo() { return ( React Native is an open-source UI software framework created by Meta. It is used to develop applications for Android, iOS, macOS, tvOS, Web, Windows and UWP by enabling developers to use React's framework along with native platform capabilities. ); } ``` #### Multiple Collapsibles **Example:** Multiple collapsible components working independently ```tsx // components/demo/collapsible/collapsible-multiple.tsx import { Collapsible } from '@/components/ui/collapsible'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function CollapsibleMultiple() { return ( To get started with React Native, you'll need to set up your development environment. This includes installing Node.js, React Native CLI, and either Android Studio or Xcode. React Native provides many built-in components like View, Text, ScrollView, TextInput, and more. You can also create custom components to build your app's interface. For navigation between screens, React Navigation is the most popular solution. It provides stack, tab, and drawer navigation patterns that work seamlessly with React Native. ); } ``` #### Nested Collapsibles **Example:** Collapsible components nested within each other ```tsx // components/demo/collapsible/collapsible-nested.tsx import { Collapsible } from '@/components/ui/collapsible'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function CollapsibleNested() { return ( Mobile development encompasses various platforms and technologies: iOS development primarily uses Swift or Objective-C with Xcode as the main development environment. Apps are distributed through the Apple App Store. Android development can be done with Java, Kotlin, or cross-platform frameworks. Android Studio is the official IDE, and apps are distributed through Google Play Store. Cross-platform frameworks like React Native, Flutter, and Xamarin allow you to write code once and deploy to multiple platforms. ); } ``` #### With Interactive Content **Example:** Collapsible containing interactive elements like checkboxes ```tsx // components/demo/collapsible/collapsible-with-content.tsx import { Checkbox } from '@/components/ui/checkbox'; import { Collapsible } from '@/components/ui/collapsible'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function CollapsibleWithContent() { const [preferences, setPreferences] = useState({ darkMode: false, notifications: true, analytics: false, }); return ( setPreferences((prev) => ({ ...prev, darkMode: checked })) } label='Enable dark mode' /> setPreferences((prev) => ({ ...prev, notifications: checked })) } label='Push notifications' /> setPreferences((prev) => ({ ...prev, analytics: checked })) } label='Analytics tracking' /> Email: user@example.com Member since: January 2024 Subscription: Premium ); } ``` #### FAQ Style **Example:** Collapsible components styled as frequently asked questions ```tsx // components/demo/collapsible/collapsible-faq.tsx import { Collapsible } from '@/components/ui/collapsible'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function CollapsibleFAQ() { const faqs = [ { question: 'How do I reset my password?', answer: "You can reset your password by clicking on the 'Forgot Password' link on the login page. Enter your email address and we'll send you instructions to reset your password.", }, { question: 'Can I cancel my subscription anytime?', answer: 'Yes, you can cancel your subscription at any time from your account settings. Your subscription will remain active until the end of your current billing period.', }, { question: 'Is my data secure?', answer: 'We take data security seriously. All data is encrypted in transit and at rest. We follow industry best practices and comply with relevant data protection regulations.', }, { question: 'How do I contact support?', answer: 'You can reach our support team through the in-app chat, email us at support@example.com, or call our toll-free number during business hours.', }, ]; return ( Frequently Asked Questions {faqs.map((faq, index) => ( {faq.answer} ))} ); } ``` ## API Reference ### Collapsible An expandable/collapsible container component that shows or hides its content. | Prop | Type | Default | Description | | ---------- | ----------- | ------- | ------------------------------------------------------------------------- | | `haptic` | `boolean` | `true` | Whether to trigger haptic feedback when the content expands or collapses. | | `title` | `string` | - | The title text displayed in the header. | | `children` | `ReactNode` | - | The content to show/hide when toggled. | ## Component Structure The Collapsible component consists of: 1. **Header**: A touchable area containing the title and chevron icon 2. **Content**: The collapsible content that shows/hides based on state 3. **Icon**: A chevron that rotates to indicate open/closed state ## Behavior - Initially collapsed by default - Clicking the header toggles the collapsed/expanded state - Smooth rotation animation for the chevron icon - Content appears with proper indentation when expanded - Each collapsible manages its own state independently ## Accessibility The Collapsible component is built with accessibility in mind: - Uses TouchableOpacity for proper touch handling - Visual indicator (chevron) shows current state - Proper spacing and indentation for content hierarchy - Supports nested content and interactive elements - Clear visual feedback with activeOpacity on touch ## Styling The component uses: - Flexible layout with proper spacing - Icon rotation animation for visual feedback - Consistent indentation for nested content - Theme-aware text styling through the Text component - Responsive touch areas for better usability ## Use Cases Perfect for: - FAQ sections - Settings panels - Navigation menus - Content organization - Progressive disclosure - Help documentation - Feature explanations # Color Picker > A color picker component with HSV color space selection and swatch display. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/color-picker - Markdown: https://ui.ahmedbna.com/docs/components/color-picker.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/color-picker.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/color-picker.json - Install: `npx bna-ui add color-picker` - npm dependencies: `expo-linear-gradient`, `react-native-gesture-handler`, `react-native-reanimated`, `react-native-svg`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `text`, `view` - Preview recording: https://demo.ahmedbna.com/0115-color-picker-demo.MP4 --- **Example:** A basic color picker with swatch and modal selection ```tsx // components/demo/color-picker/color-picker-demo.tsx import { ColorPicker } from '@/components/ui/color-picker'; import React, { useState } from 'react'; export function ColorPickerDemo() { const [color, setColor] = useState('#ff0000'); return ( { console.log('Color selected:', selectedColor); setColor(selectedColor); }} /> ); } ``` ## Installation ### CLI ```bash npx bna-ui add color-picker ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install expo-linear-gradient react-native-gesture-handler react-native-reanimated react-native-svg ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/color-picker.tsx import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { BORDER_RADIUS, CORNERS, FONT_SIZE, HEIGHT } from '@/theme/globals'; import { LinearGradient as ExpoLinearGradient } from 'expo-linear-gradient'; import React, { useCallback, useEffect, useState } from 'react'; import { Dimensions, Modal, StyleSheet, TextInput, TouchableOpacity, ViewStyle, } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { runOnJS, useAnimatedStyle, useSharedValue, } from 'react-native-reanimated'; import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg'; const { width: screenWidth } = Dimensions.get('window'); const PICKER_SIZE = screenWidth - 40; const HUE_BAR_HEIGHT = 40; const KNOB_SIZE = 40; // Color utility functions const hsvToRgb = (h: number, s: number, v: number) => { const c = v * s; const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); const m = v - c; let r = 0, g = 0, b = 0; if (0 <= h && h < 60) { r = c; g = x; b = 0; } else if (60 <= h && h < 120) { r = x; g = c; b = 0; } else if (120 <= h && h < 180) { r = 0; g = c; b = x; } else if (180 <= h && h < 240) { r = 0; g = x; b = c; } else if (240 <= h && h < 300) { r = x; g = 0; b = c; } else if (300 <= h && h < 360) { r = c; g = 0; b = x; } return { r: Math.round((r + m) * 255), g: Math.round((g + m) * 255), b: Math.round((b + m) * 255), }; }; const rgbToHex = (r: number, g: number, b: number) => { return `#${r.toString(16).padStart(2, '0')}${g .toString(16) .padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; }; const hexToRgb = (hex: string) => { let normalized = hex.replace('#', ''); if (normalized.length === 3) { normalized = normalized .split('') .map((c) => c + c) .join(''); } else if (normalized.length === 8) { // Drop the alpha channel — this picker has no alpha concept downstream. normalized = normalized.slice(0, 6); } const result = /^([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(normalized); return result ? { r: parseInt(result[1], 16), g: parseInt(result[2], 16), b: parseInt(result[3], 16), } : { r: 0, g: 0, b: 0 }; }; const isValidHex = (hex: string) => /^#?([a-f\d]{3}|[a-f\d]{6}|[a-f\d]{8})$/i.test(hex); const rgbToHsv = (r: number, g: number, b: number) => { r /= 255; g /= 255; b /= 255; const max = Math.max(r, g, b); const min = Math.min(r, g, b); const diff = max - min; let h = 0; if (diff !== 0) { if (max === r) { h = ((g - b) / diff) % 6; } else if (max === g) { h = (b - r) / diff + 2; } else { h = (r - g) / diff + 4; } } h = Math.round(h * 60); if (h < 0) h += 360; const s = max === 0 ? 0 : diff / max; const v = max; return { h, s, v }; }; interface ColorSwatchProps { color: string; size?: number; style?: ViewStyle; onPress?: () => void; accessibilityLabel?: string; } export const ColorSwatch: React.FC = ({ color, size = 32, style, onPress, accessibilityLabel, }) => { const borderColor = useColor('border'); return ( ); }; interface ColorPickerProps { value?: string; onColorChange?: (color: string) => void; onColorSelect?: (color: string) => void; swatchSize?: number; disabled?: boolean; style?: ViewStyle; } export const ColorPicker: React.FC = ({ value = '#ff0000', onColorChange, onColorSelect, swatchSize = HEIGHT, disabled = false, style, }) => { const [isModalVisible, setIsModalVisible] = useState(false); const [currentColor, setCurrentColor] = useState(value); const [pureHueColor, setPureHueColor] = useState('#ff0000'); const [hexInput, setHexInput] = useState(value); const backgroundColor = useColor('background'); const card = useColor('card'); const borderColor = useColor('border'); const textColor = useColor('text'); // Initialize HSV values from the current color const rgb = hexToRgb(currentColor); const initialHsv = rgbToHsv(rgb.r, rgb.g, rgb.b); const hue = useSharedValue(initialHsv.h); const saturation = useSharedValue(initialHsv.s); const brightness = useSharedValue(initialHsv.v); const updateColor = useCallback( (h: number, s: number, v: number) => { const rgb = hsvToRgb(h, s, v); const hex = rgbToHex(rgb.r, rgb.g, rgb.b); setCurrentColor(hex); onColorChange?.(hex); // Update pure hue color for the saturation/brightness picker const pureRgb = hsvToRgb(h, 1, 1); const pureHex = rgbToHex(pureRgb.r, pureRgb.g, pureRgb.b); setPureHueColor(pureHex); }, [onColorChange] ); // Update pure hue color when modal opens useEffect(() => { if (isModalVisible) { const rgb = hexToRgb(currentColor); const hsv = rgbToHsv(rgb.r, rgb.g, rgb.b); hue.value = hsv.h; saturation.value = hsv.s; brightness.value = hsv.v; const pureRgb = hsvToRgb(hsv.h, 1, 1); const pureHex = rgbToHex(pureRgb.r, pureRgb.g, pureRgb.b); setPureHueColor(pureHex); setHexInput(currentColor); } }, [isModalVisible, currentColor]); // Dragging previously called runOnJS(updateColor) on every pan frame, // forcing a full JS re-render per touch-move. Live visuals (preview swatch // + knob positions) now derive purely from shared values on the UI // thread; the JS-side state commit (currentColor, onColorChange) happens // once, on gesture end. const hueGesture = Gesture.Pan() .onUpdate((event) => { const newX = Math.max( 0, Math.min(PICKER_SIZE - KNOB_SIZE, event.x - KNOB_SIZE / 2) ); hue.value = (newX / (PICKER_SIZE - KNOB_SIZE)) * 360; }) .onEnd(() => { runOnJS(updateColor)(hue.value, saturation.value, brightness.value); }); // Saturation/Brightness picker gesture using new Gesture API const pickerGesture = Gesture.Pan() .onUpdate((event) => { const newX = Math.max( 0, Math.min(PICKER_SIZE - KNOB_SIZE, event.x - KNOB_SIZE / 2) ); const newY = Math.max( 0, Math.min(PICKER_SIZE - KNOB_SIZE, event.y - KNOB_SIZE / 2) ); saturation.value = newX / (PICKER_SIZE - KNOB_SIZE); brightness.value = 1 - newY / (PICKER_SIZE - KNOB_SIZE); }) .onEnd(() => { runOnJS(updateColor)(hue.value, saturation.value, brightness.value); }); const hueKnobStyle = useAnimatedStyle(() => ({ transform: [{ translateX: (hue.value / 360) * (PICKER_SIZE - KNOB_SIZE) }], })); const pickerKnobStyle = useAnimatedStyle(() => ({ transform: [ { translateX: saturation.value * (PICKER_SIZE - KNOB_SIZE) }, { translateY: (1 - brightness.value) * (PICKER_SIZE - KNOB_SIZE) }, ], })); // Live preview swatch, driven entirely on the UI thread so dragging never // triggers a JS re-render — hsvToRgb is auto-workletized by the Reanimated // Babel plugin since it's referenced from inside this worklet. const previewAnimatedStyle = useAnimatedStyle(() => { const rgb = hsvToRgb(hue.value, saturation.value, brightness.value); return { backgroundColor: `rgb(${rgb.r}, ${rgb.g}, ${rgb.b})`, }; }); const handleHexSubmit = useCallback(() => { if (!isValidHex(hexInput)) { setHexInput(currentColor); return; } const normalized = hexInput.startsWith('#') ? hexInput : `#${hexInput}`; const rgb = hexToRgb(normalized); const hsv = rgbToHsv(rgb.r, rgb.g, rgb.b); hue.value = hsv.h; saturation.value = hsv.s; brightness.value = hsv.v; updateColor(hsv.h, hsv.s, hsv.v); }, [hexInput, currentColor, updateColor]); const handleColorSelect = () => { onColorSelect?.(currentColor); setIsModalVisible(false); }; const handleCancel = () => { setCurrentColor(value); setIsModalVisible(false); }; return ( setIsModalVisible(true)} accessibilityLabel={`Selected color ${currentColor}. Opens color picker.`} /> Cancel Choose Color Done {/* Color Preview */} {currentColor.toUpperCase()} {/* Manual hex entry — the drag-only pickers below are not operable via VoiceOver/TalkBack/switch control, so this is the accessible path to picking an exact color. */} {/* Saturation/Brightness Picker */} {/* Base color layer */} {/* Saturation gradient (white to transparent, left to right) */} {/* Brightness gradient (transparent to black, top to bottom) */} {/* Hue Bar */} ); }; const styles = StyleSheet.create({ modalContainer: { flex: 1, }, header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 20, paddingVertical: 16, }, content: { flex: 1, padding: 20, alignItems: 'center', }, pickerContainer: { marginBottom: 30, borderRadius: BORDER_RADIUS, overflow: 'hidden', }, colorBase: { position: 'absolute', width: PICKER_SIZE, height: PICKER_SIZE, borderRadius: BORDER_RADIUS, }, gradientLayer: { position: 'absolute', width: PICKER_SIZE, height: PICKER_SIZE, borderRadius: 12, }, pickerKnob: { position: 'absolute', width: KNOB_SIZE, height: KNOB_SIZE, borderRadius: CORNERS, backgroundColor: 'white', borderWidth: 1, borderColor: '#000', shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.3, shadowRadius: 4, elevation: 5, }, hueContainer: { borderRadius: CORNERS, overflow: 'hidden', }, hueKnob: { position: 'absolute', width: KNOB_SIZE, height: KNOB_SIZE, borderRadius: CORNERS, backgroundColor: 'white', opacity: 0.5, borderWidth: 2, borderColor: '#000', shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.3, shadowRadius: 4, elevation: 5, }, }); ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { ColorPicker, ColorSwatch } from '@/components/ui/color-picker'; ``` ```tsx console.log('Color changed:', color)} onColorSelect={(color) => console.log('Color selected:', color)} /> ``` ## Examples #### Default **Example:** A basic color picker with swatch and modal selection ```tsx // components/demo/color-picker/color-picker-demo.tsx import { ColorPicker } from '@/components/ui/color-picker'; import React, { useState } from 'react'; export function ColorPickerDemo() { const [color, setColor] = useState('#ff0000'); return ( { console.log('Color selected:', selectedColor); setColor(selectedColor); }} /> ); } ``` #### Different Sizes **Example:** Color pickers with different swatch sizes ```tsx // components/demo/color-picker/color-picker-sizes.tsx import { ColorPicker } from '@/components/ui/color-picker'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function ColorPickerSizes() { const [smallColor, setSmallColor] = useState('#ff6b6b'); const [mediumColor, setMediumColor] = useState('#4ecdc4'); const [largeColor, setLargeColor] = useState('#45b7d1'); const [xlColor, setXlColor] = useState('#f9ca24'); return ( ); } ``` #### With Initial Colors **Example:** Color pickers with different initial colors ```tsx // components/demo/color-picker/color-picker-colors.tsx import { ColorPicker } from '@/components/ui/color-picker'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function ColorPickerColors() { const [redColor, setRedColor] = useState('#e74c3c'); const [greenColor, setGreenColor] = useState('#2ecc71'); const [blueColor, setBlueColor] = useState('#3498db'); const [purpleColor, setPurpleColor] = useState('#9b59b6'); const [orangeColor, setOrangeColor] = useState('#f39c12'); const colorData = [ { name: 'Red', color: redColor, setter: setRedColor }, { name: 'Green', color: greenColor, setter: setGreenColor }, { name: 'Blue', color: blueColor, setter: setBlueColor }, { name: 'Purple', color: purpleColor, setter: setPurpleColor }, { name: 'Orange', color: orangeColor, setter: setOrangeColor }, ]; return ( {colorData.map(({ name, color, setter }) => ( {name} ))} ); } ``` #### Disabled State **Example:** Disabled color picker that cannot be opened ```tsx // components/demo/color-picker/color-picker-disabled.tsx import { ColorPicker } from '@/components/ui/color-picker'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function ColorPickerDisabled() { return ( Disabled Color Picker ); } ``` #### Color Swatch Only **Example:** Standalone color swatches without picker functionality ```tsx // components/demo/color-picker/color-swatch-demo.tsx import { ColorSwatch } from '@/components/ui/color-picker'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function ColorSwatchDemo() { const [selectedColor, setSelectedColor] = useState('#e74c3c'); const colors = [ '#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#e67e22', '#34495e', ]; return ( {colors.map((color) => ( setSelectedColor(color)} style={{ borderWidth: selectedColor === color ? 3 : 2, borderColor: selectedColor === color ? '#000' : 'transparent', }} /> ))} Selected: {selectedColor.toUpperCase()} ); } ``` #### Custom Styling **Example:** Color pickers with custom styling and layouts ```tsx // components/demo/color-picker/color-picker-styled.tsx import { ColorPicker } from '@/components/ui/color-picker'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function ColorPickerStyled() { const [primaryColor, setPrimaryColor] = useState('#007AFF'); const [accentColor, setAccentColor] = useState('#FF3B30'); return ( {/* Rounded Square Style */} Primary Color {/* With Custom Border */} Accent Color ); } ``` #### Color Palette **Example:** Multiple color pickers arranged as a color palette ```tsx // components/demo/color-picker/color-picker-palette.tsx import { ColorPicker } from '@/components/ui/color-picker'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import React, { useState } from 'react'; export function ColorPickerPalette() { const cardColor = useColor('card'); const [colors, setColors] = useState([ '#FF6B6B', '#4ECDC4', '#45B7D1', '#F9CA24', '#6C5CE7', '#A29BFE', '#FD79A8', '#00B894', ]); const updateColor = (index: number, newColor: string) => { const newColors = [...colors]; newColors[index] = newColor; setColors(newColors); }; return ( {colors.map((color, index) => ( updateColor(index, newColor)} onColorSelect={(newColor) => updateColor(index, newColor)} swatchSize={36} /> {color.toUpperCase()} ))} ); } ``` #### With Labels **Example:** Color pickers with descriptive labels ```tsx // components/demo/color-picker/color-picker-labeled.tsx import { ColorPicker } from '@/components/ui/color-picker'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function ColorPickerLabeled() { const [backgroundColor, setBackgroundColor] = useState('#ffffff'); const [textColor, setTextColor] = useState('#333333'); const [borderColor, setBorderColor] = useState('#e1e5e9'); return ( {/* Background Color */} Background Color {backgroundColor.toUpperCase()} {/* Text Color */} Text Color {textColor.toUpperCase()} {/* Border Color */} Border Color {borderColor.toUpperCase()} {/* Preview */} Preview with selected colors ); } ``` ## API Reference ### ColorPicker The main color picker component that displays a swatch and opens a modal for color selection. | Prop | Type | Default | Description | | --------------- | ------------------------- | ----------- | ------------------------------------------------------- | | `value` | `string` | `'#ff0000'` | The current color value in hex format. | | `onColorChange` | `(color: string) => void` | - | Callback fired when the color changes during selection. | | `onColorSelect` | `(color: string) => void` | - | Callback fired when the user confirms color selection. | | `swatchSize` | `number` | `HEIGHT` | The size of the color swatch in pixels. | | `disabled` | `boolean` | `false` | Whether the color picker is disabled. | | `style` | `ViewStyle` | - | Additional styles to apply to the container. | ### ColorSwatch A standalone color swatch component that can be used independently. | Prop | Type | Default | Description | | -------------------- | ------------ | ------- | ------------------------------------------------------------------------------------------ | | `color` | `string` | - | The color to display in hex format. | | `size` | `number` | `32` | The size of the swatch in pixels. | | `style` | `ViewStyle` | - | Additional styles to apply to the swatch. | | `onPress` | `() => void` | - | Callback fired when the swatch is pressed. | | `accessibilityLabel` | `string` | - | Accessibility label for screen readers. Defaults to `"Color swatch {color}"` when omitted. | ## Color Utilities The component includes several utility functions for color manipulation: - `hsvToRgb(h, s, v)` - Converts HSV values to RGB - `rgbToHex(r, g, b)` - Converts RGB values to hex string - `hexToRgb(hex)` - Converts hex string to RGB values - `rgbToHsv(r, g, b)` - Converts RGB values to HSV ## Features - **HSV Color Space**: Uses HSV (Hue, Saturation, Value) color model for intuitive color selection - **Interactive Gestures**: Pan gestures for hue bar and saturation/brightness picker - **Real-time Preview**: Live color preview as you drag the selectors - **Modal Interface**: Full-screen modal with cancel and confirm actions - **Customizable**: Configurable swatch sizes and styling - **Accessible**: Proper contrast and readable color values - **Smooth Animations**: Powered by react-native-reanimated for smooth interactions ## Accessibility The ColorPicker component is built with accessibility in mind: - Color values are displayed in uppercase hex format for easy reading - High contrast knobs for better visibility - Proper touch targets for gesture interactions - Modal provides clear cancel and confirm actions - Color preview shows the selected color prominently ## Notes - The component uses `expo-linear-gradient` for smooth color gradients - Gesture handling is powered by `react-native-gesture-handler` - Animations use `react-native-reanimated` for optimal performance - The hue bar uses SVG gradients for precise color representation - Color calculations maintain precision across different color spaces # Combobox > A searchable dropdown component that combines an input with a list of options. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/combobox - Markdown: https://ui.ahmedbna.com/docs/components/combobox.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/combobox.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/combobox.json - Install: `npx bna-ui add combobox` - npm dependencies: `expo-haptics`, `lucide-react-native` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `useHaptics`, `globals` - Preview recording: https://demo.ahmedbna.com/0123-combobox-demo.MP4 --- **Example:** A basic combobox with search functionality ```tsx // components/demo/combobox/combobox-demo.tsx import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList, ComboboxTrigger, ComboboxValue, OptionType, } from '@/components/ui/combobox'; import React, { useState } from 'react'; const frameworks: OptionType[] = [ { value: 'react', label: 'React' }, { value: 'vue', label: 'Vue' }, { value: 'angular', label: 'Angular' }, { value: 'svelte', label: 'Svelte' }, { value: 'next', label: 'Next.js' }, { value: 'nuxt', label: 'Nuxt.js' }, ]; export function ComboboxDemo() { const [value, setValue] = useState(null); return ( No framework found. {frameworks.map((framework) => ( {framework.label} ))} ); } ``` ## Installation ### CLI ```bash npx bna-ui add combobox ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install lucide-react-native ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/combobox.tsx import { useColor } from '@/hooks/useColor'; import { useHaptics } from '@/hooks/useHaptics'; import { BORDER_RADIUS, CORNERS, FONT_SIZE, HEIGHT } from '@/theme/globals'; import { ChevronDown } from 'lucide-react-native'; import React, { Children, cloneElement, createContext, isValidElement, ReactNode, useContext, useEffect, useMemo, useRef, useState, } from 'react'; import { Dimensions, Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, TextStyle, TouchableOpacity, View, ViewStyle, } from 'react-native'; // --- 1. DEFINE A SHARED OPTION TYPE --- export interface OptionType { value: string; label: string; } // Helper to extract a simple string label from children const getLabelFromChildren = (children: ReactNode): string => { let label = ''; React.Children.forEach(children, (child) => { if (typeof child === 'string' || typeof child === 'number') { label += child; } }); return label; }; interface ComboboxContextType { isOpen: boolean; setIsOpen: (open: boolean) => void; value: OptionType | null; setValue: (option: OptionType) => void; searchQuery: string; setSearchQuery: (query: string) => void; triggerLayout: { x: number; y: number; width: number; height: number }; setTriggerLayout: (layout: any) => void; disabled: boolean; multiple: boolean; values: OptionType[]; setValues: (options: OptionType[]) => void; filteredItemsCount: number; setFilteredItemsCount: (count: number) => void; haptic: boolean; } const ComboboxContext = createContext( undefined ); const useCombobox = () => { const context = useContext(ComboboxContext); if (!context) { throw new Error('Combobox components must be used within a Combobox'); } return context; }; interface ComboboxProps { children: ReactNode; value?: OptionType | null; onValueChange?: (option: OptionType | null) => void; disabled?: boolean; multiple?: boolean; values?: OptionType[]; onValuesChange?: (options: OptionType[]) => void; haptic?: boolean; } export function Combobox({ children, value = null, onValueChange, disabled = false, multiple = false, values = [], onValuesChange, haptic = true, }: ComboboxProps) { const [isOpen, setIsOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [filteredItemsCount, setFilteredItemsCount] = useState(0); const [triggerLayout, setTriggerLayout] = useState({ x: 0, y: 0, width: 0, height: 0, }); const setValue = (newOption: OptionType) => { if (multiple) { const isAlreadySelected = values.some((v) => v.value === newOption.value); const newValues = isAlreadySelected ? values.filter((v) => v.value !== newOption.value) : [...values, newOption]; onValuesChange?.(newValues); } else { onValueChange?.(newOption); } }; const setValues = (newOptions: OptionType[]) => { onValuesChange?.(newOptions); }; return ( {children} ); } interface ComboboxTriggerProps { children: ReactNode; style?: ViewStyle; error?: boolean; } export function ComboboxTrigger({ children, style, error = false, }: ComboboxTriggerProps) { const { setIsOpen, setTriggerLayout, disabled, isOpen, haptic } = useCombobox(); const triggerRef = useRef>(null); const cardColor = useColor('card'); const destructiveColor = useColor('destructive'); const mutedColor = useColor('textMuted'); const feedback = useHaptics(haptic); const measureTrigger = () => { if (triggerRef.current) { triggerRef.current.measure((_x, _y, width, height, pageX, pageY) => { setTriggerLayout({ x: pageX, y: pageY, width, height }); }); } }; const handlePress = () => { if (disabled) return; feedback('impact-light'); measureTrigger(); setIsOpen(true); }; return ( {children} ); } interface ComboboxValueProps { placeholder?: string; style?: TextStyle; } export function ComboboxValue({ placeholder = 'Select...', style, }: ComboboxValueProps) { const { value, values, multiple } = useCombobox(); const textColor = useColor('text'); const mutedColor = useColor('textMuted'); const hasValue = multiple ? values.length > 0 : !!value; const displayText = multiple ? values.length === 0 ? placeholder : values.length === 1 ? values[0].label : `${values.length} selected` : value?.label || placeholder; return ( {displayText} ); } interface ComboboxContentProps { children: ReactNode; maxHeight?: number; } export function ComboboxContent({ children, maxHeight = 400, }: ComboboxContentProps) { const { isOpen, setIsOpen, setSearchQuery, triggerLayout } = useCombobox(); const cardColor = useColor('card'); const borderColor = useColor('border'); const handleClose = () => { setIsOpen(false); setSearchQuery(''); }; const screenHeight = Dimensions.get('window').height; const availableHeight = screenHeight - triggerLayout.y - triggerLayout.height - 100; const dropdownHeight = Math.min(maxHeight, availableHeight); if (!isOpen) { return null; } return ( {children} ); } interface ComboboxInputProps { placeholder?: string; style?: ViewStyle; autoFocus?: boolean; } export function ComboboxInput({ placeholder = 'Search...', style, autoFocus = true, }: ComboboxInputProps) { const { searchQuery, setSearchQuery } = useCombobox(); const textColor = useColor('text'); const mutedColor = useColor('textMuted'); const borderColor = useColor('border'); return ( ); } interface ComboboxListProps { children: ReactNode; style?: ViewStyle; } const countFilteredItems = (nodes: React.ReactNode[]): number => { return nodes.reduce((count, node) => { if (isValidElement(node)) { if (node.type === ComboboxItem) { return count + 1; } if (node.type === ComboboxGroup) { const groupChildren = Children.toArray((node.props as any).children); return count + countFilteredItems(groupChildren); } } return count; }, 0); }; export function ComboboxList({ children, style }: ComboboxListProps) { const { searchQuery, setFilteredItemsCount } = useCombobox(); // Filtering walks the whole children tree (and every group's children) — // memoize rather than redoing that walk, plus the item-count reduction // over the result, on every render regardless of whether children or the // query actually changed. const [filteredChildren, itemCount] = useMemo(() => { const filtered = Children.toArray(children).filter((child) => { if (!searchQuery) return true; if (isValidElement(child) && child.type === ComboboxItem) { const props = child.props as any; const label = getLabelFromChildren(props.children); const searchText = props.searchValue || label || props.value || ''; return searchText.toLowerCase().includes(searchQuery.toLowerCase()); } if (isValidElement(child) && child.type === ComboboxGroup) { const groupProps = child.props as any; const groupChildren = Children.toArray(groupProps.children); return groupChildren.some((groupChild) => { if (isValidElement(groupChild) && groupChild.type === ComboboxItem) { const itemProps = groupChild.props as any; const label = getLabelFromChildren(itemProps.children); const searchText = itemProps.searchValue || label || itemProps.value || ''; return searchText.toLowerCase().includes(searchQuery.toLowerCase()); } return false; }); } return true; }); return [filtered, countFilteredItems(filtered)] as const; }, [children, searchQuery]); useEffect(() => { setFilteredItemsCount(itemCount); }, [itemCount, setFilteredItemsCount]); return ( {filteredChildren} ); } interface ComboboxEmptyProps { children: ReactNode; style?: ViewStyle; } export function ComboboxEmpty({ children, style }: ComboboxEmptyProps) { const { searchQuery, filteredItemsCount } = useCombobox(); const mutedColor = useColor('textMuted'); if (filteredItemsCount > 0) return null; return ( {typeof children === 'string' ? ( {children} ) : ( children )} ); } interface ComboboxGroupProps { children: ReactNode; heading?: string; } export function ComboboxGroup({ children, heading }: ComboboxGroupProps) { const { searchQuery } = useCombobox(); const mutedColor = useColor('textMuted'); const filteredChildren = Children.toArray(children).filter((child) => { if (!searchQuery) return true; if (isValidElement(child) && child.type === ComboboxItem) { const props = child.props as any; const label = getLabelFromChildren(props.children); const searchText = props.searchValue || label || props.value || ''; return searchText.toLowerCase().includes(searchQuery.toLowerCase()); } return true; }); if (searchQuery && filteredChildren.length === 0) return null; return ( {heading && ( {heading} )} {filteredChildren} ); } interface ComboboxItemProps { children: ReactNode; value: string; // The unique value is still a string onSelect?: (value: OptionType) => void; disabled?: boolean; searchValue?: string; style?: ViewStyle; } export function ComboboxItem({ children, value: itemValue, onSelect, disabled = false, style, }: ComboboxItemProps) { const { setValue, setIsOpen, multiple, values: selectedValues, value: selectedValue, haptic, } = useCombobox(); const textColor = useColor('text'); const primaryColor = useColor('primary'); const feedback = useHaptics(haptic); const isSelected = multiple ? selectedValues.some((v) => v.value === itemValue) : selectedValue?.value === itemValue; const handleSelect = () => { if (disabled) return; // Multi-select rows toggle, single-select rows pick — they should not feel // the same. feedback( multiple ? (isSelected ? 'toggle-off' : 'toggle-on') : 'selection' ); const label = getLabelFromChildren(children); const selectedOption: OptionType = { value: itemValue, label }; onSelect?.(selectedOption); setValue(selectedOption); if (!multiple) { setIsOpen(false); } }; return ( {typeof children === 'string' ? ( {children} ) : ( Children.map(children, (child) => { if (isValidElement(child)) { return cloneElement(child, { isSelected } as any); } return child; }) )} ); } const styles = StyleSheet.create({ trigger: { height: HEIGHT, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 16, borderRadius: CORNERS, borderWidth: 1, }, triggerContent: { flex: 1, flexDirection: 'row', alignItems: 'center', }, valueText: { fontSize: FONT_SIZE, flex: 1, }, chevron: { marginLeft: 8, }, overlay: { flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.3)', }, dropdown: { position: 'absolute', borderRadius: BORDER_RADIUS, borderWidth: 1, shadowColor: '#000', shadowOffset: { width: 0, height: 2, }, shadowOpacity: 0.25, shadowRadius: 3.84, elevation: 5, }, searchContainer: { paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1, height: HEIGHT, }, searchInput: { fontSize: FONT_SIZE, flex: 1, }, optionsList: { maxHeight: 400, }, emptyContainer: { padding: 16, alignItems: 'center', }, emptyText: { fontSize: FONT_SIZE, fontStyle: 'italic', }, groupHeading: { fontSize: 12, fontWeight: '600', paddingHorizontal: 16, paddingVertical: 8, textTransform: 'uppercase', letterSpacing: 0.5, }, option: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 12, minHeight: 44, }, optionText: { fontSize: FONT_SIZE, flex: 1, }, }); ``` **3.** Update the import paths to match your project setup. ## Usage When managing state for the combobox, you will work with an `OptionType` object (`{ value: string; label: string; }`) or an array of these objects for multiple selections. ```tsx import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxGroup, ComboboxInput, ComboboxItem, ComboboxList, ComboboxTrigger, ComboboxValue, OptionType, // Import the type } from '@/components/ui/combobox'; import { useState } from 'react'; // ... const [value, setValue] = useState(null); // ... No framework found. React Vue Angular ; ``` ## Examples #### Default **Example:** A basic combobox with search functionality ```tsx // components/demo/combobox/combobox-demo.tsx import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList, ComboboxTrigger, ComboboxValue, OptionType, } from '@/components/ui/combobox'; import React, { useState } from 'react'; const frameworks: OptionType[] = [ { value: 'react', label: 'React' }, { value: 'vue', label: 'Vue' }, { value: 'angular', label: 'Angular' }, { value: 'svelte', label: 'Svelte' }, { value: 'next', label: 'Next.js' }, { value: 'nuxt', label: 'Nuxt.js' }, ]; export function ComboboxDemo() { const [value, setValue] = useState(null); return ( No framework found. {frameworks.map((framework) => ( {framework.label} ))} ); } ``` #### With Groups **Example:** Combobox with grouped options ```tsx // components/demo/combobox/combobox-groups.tsx import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxGroup, ComboboxInput, ComboboxItem, ComboboxList, ComboboxTrigger, ComboboxValue, OptionType, } from '@/components/ui/combobox'; import React, { useState } from 'react'; export function ComboboxGroups() { const [value, setValue] = useState(null); return ( No technology found. React Vue Angular Svelte Express.js Fastify NestJS Koa React Native Flutter Ionic ); } ``` #### Multiple Selection **Example:** Combobox that allows selecting multiple values ```tsx // components/demo/combobox/combobox-multiple.tsx import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList, ComboboxTrigger, ComboboxValue, OptionType, } from '@/components/ui/combobox'; import React, { useState } from 'react'; const skills: OptionType[] = [ { value: 'javascript', label: 'JavaScript' }, { value: 'typescript', label: 'TypeScript' }, { value: 'react', label: 'React' }, { value: 'vue', label: 'Vue' }, { value: 'angular', label: 'Angular' }, { value: 'nodejs', label: 'Node.js' }, { value: 'python', label: 'Python' }, { value: 'java', label: 'Java' }, { value: 'csharp', label: 'C#' }, { value: 'go', label: 'Go' }, ]; export function ComboboxMultiple() { const [values, setValues] = useState([]); return ( No skill found. {skills.map((skill) => ( {skill.label} ))} ); } ``` #### Disabled **Example:** Disabled combobox component ```tsx // components/demo/combobox/combobox-disabled.tsx import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList, ComboboxTrigger, ComboboxValue, OptionType, } from '@/components/ui/combobox'; import React, { useState } from 'react'; const frameworks: OptionType[] = [ { value: 'react', label: 'React' }, { value: 'vue', label: 'Vue' }, { value: 'angular', label: 'Angular' }, ]; export function ComboboxDisabled() { const [value, setValue] = useState({ value: 'vue', label: 'Vue', }); return ( No framework found. {frameworks.map((framework) => ( {framework.label} ))} ); } ``` #### With Custom Search **Example:** Combobox with custom search behavior ```tsx // components/demo/combobox/combobox-search.tsx import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList, ComboboxTrigger, ComboboxValue, OptionType, } from '@/components/ui/combobox'; import React, { useState } from 'react'; // For clarity, define a type for the country data that includes searchValue interface CountryOption extends OptionType { searchValue: string; } const countries: CountryOption[] = [ { value: 'us', label: 'United States', searchValue: 'united states america usa', }, { value: 'uk', label: 'United Kingdom', searchValue: 'united kingdom england britain uk', }, { value: 'ca', label: 'Canada', searchValue: 'canada canadian' }, { value: 'au', label: 'Australia', searchValue: 'australia australian aussie', }, { value: 'de', label: 'Germany', searchValue: 'germany german deutschland' }, { value: 'fr', label: 'France', searchValue: 'france french français' }, { value: 'jp', label: 'Japan', searchValue: 'japan japanese nihon' }, { value: 'cn', label: 'China', searchValue: 'china chinese zhongguo' }, ]; export function ComboboxSearch() { const [value, setValue] = useState(null); return ( No country found. {countries.map((country) => ( {country.label} ))} ); } ``` #### Form Integration **Example:** Combobox integrated with form validation ```tsx // components/demo/combobox/combobox-form.tsx import { Button } from '@/components/ui/button'; import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList, ComboboxTrigger, ComboboxValue, OptionType, } from '@/components/ui/combobox'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; const roles: OptionType[] = [ { value: 'frontend', label: 'Frontend Developer' }, { value: 'backend', label: 'Backend Developer' }, { value: 'fullstack', label: 'Full Stack Developer' }, { value: 'mobile', label: 'Mobile Developer' }, { value: 'devops', label: 'DevOps Engineer' }, { value: 'qa', label: 'QA Engineer' }, { value: 'designer', label: 'UI/UX Designer' }, ]; export function ComboboxForm() { const [selectedRole, setSelectedRole] = useState(null); const [error, setError] = useState(''); const [submitted, setSubmitted] = useState(false); const handleSubmit = () => { if (!selectedRole) { setError('Please select a role'); return; } setError(''); setSubmitted(true); // In a real app, you would submit `selectedRole.value` console.log('Submitting role:', selectedRole.value); // Reset after 2 seconds setTimeout(() => { setSubmitted(false); // 4. Reset the state back to `null`, not an empty string setSelectedRole(null); }, 2000); }; return ( Job Role * { setSelectedRole(value); if (value) { setError(''); } }} > No role found. {roles.map((role) => ( {role.label} ))} {error && ( {error} )} ); } ``` #### Large Dataset **Example:** Combobox handling large datasets efficiently ```tsx // components/demo/combobox/combobox-large.tsx import { Combobox, ComboboxContent, ComboboxEmpty, ComboboxInput, ComboboxItem, ComboboxList, ComboboxTrigger, ComboboxValue, OptionType, } from '@/components/ui/combobox'; import React, { useState } from 'react'; // For clarity, define a more specific type for the dataset items interface LargeDatasetItem extends OptionType { searchValue: string; } // Generate a large dataset const generateLargeDataset = (): LargeDatasetItem[] => { const categories = [ 'Technology', 'Science', 'Arts', 'Sports', 'Business', 'Health', ]; const adjectives = [ 'Amazing', 'Innovative', 'Creative', 'Dynamic', 'Efficient', 'Modern', ]; const nouns = [ 'Solution', 'Platform', 'System', 'Framework', 'Tool', 'Service', ]; const items: LargeDatasetItem[] = []; for (let i = 0; i < 200; i++) { const category = categories[i % categories.length]; const adjective = adjectives[Math.floor(Math.random() * adjectives.length)]; const noun = nouns[Math.floor(Math.random() * nouns.length)]; items.push({ value: `item-${i}`, label: `${adjective} ${category} ${noun} ${i + 1}`, searchValue: `${category} ${adjective} ${noun}`, }); } return items; }; const largeDataset = generateLargeDataset(); export function ComboboxLarge() { const [value, setValue] = useState(null); return ( No items found in dataset. {largeDataset.map((item) => ( {item.label} ))} ); } ``` ## API Reference The Combobox component operates on an `OptionType` object for its state, which has the shape `{ value: string; label: string; }`. The `value` prop of `ComboboxItem` should still be a unique `string`. ### Combobox The root component that manages the state and context for all child components. | Prop | Type | Default | Description | | ---------------- | -------------------------------------- | ------- | --------------------------------------------------------- | | `children` | `ReactNode` | - | The combobox components. | | `value` | `OptionType \| null` | `null` | The selected option object (for single selection). | | `onValueChange` | `(option: OptionType \| null) => void` | - | Callback when a single option object changes. | | `values` | `OptionType[]` | `[]` | An array of selected option objects (multiple selection). | | `onValuesChange` | `(options: OptionType[]) => void` | - | Callback when the array of selected options changes. | | `disabled` | `boolean` | `false` | If true, the combobox is disabled. | | `multiple` | `boolean` | `false` | If true, allows multiple selections. | ### ComboboxTrigger The button that triggers the combobox dropdown. | Prop | Type | Default | Description | | ---------- | ----------- | ------- | -------------------------------------------- | | `children` | `ReactNode` | - | The trigger content (usually ComboboxValue). | | `style` | `ViewStyle` | - | Additional styles for the trigger. | | `error` | `boolean` | `false` | If true, shows error styling on the border. | ### ComboboxValue Displays the selected value(s) or placeholder text. | Prop | Type | Default | Description | | ------------- | ----------- | ------------- | -------------------------------------- | | `placeholder` | `string` | `"Select..."` | Placeholder text when no value is set. | | `style` | `TextStyle` | - | Additional styles for the text. | ### ComboboxContent The modal container for the dropdown content. | Prop | Type | Default | Description | | ----------- | ----------- | ------- | ------------------------------- | | `children` | `ReactNode` | - | The dropdown content. | | `maxHeight` | `number` | `400` | Maximum height of the dropdown. | ### ComboboxInput The search input field within the dropdown. | Prop | Type | Default | Description | | ------------- | ----------- | ------------- | ------------------------------------ | | `placeholder` | `string` | `"Search..."` | Placeholder text for the input. | | `style` | `ViewStyle` | - | Additional styles for the container. | | `autoFocus` | `boolean` | `true` | If true, auto-focuses the input. | ### ComboboxList A scrollable container for the list of options with filtering capability. | Prop | Type | Default | Description | | ---------- | ----------- | ------- | ------------------------------- | | `children` | `ReactNode` | - | The list items and groups. | | `style` | `ViewStyle` | - | Additional styles for the list. | ### ComboboxEmpty Displays when no items match the search query. | Prop | Type | Default | Description | | ---------- | ----------- | ------- | ------------------------------------ | | `children` | `ReactNode` | - | The empty state content. | | `style` | `ViewStyle` | - | Additional styles for the container. | ### ComboboxGroup Groups related options with an optional heading. | Prop | Type | Default | Description | | ---------- | ----------- | ------- | ------------------------------- | | `children` | `ReactNode` | - | The group items. | | `heading` | `string` | - | Optional heading for the group. | ### ComboboxItem An individual selectable option within the combobox. | Prop | Type | Default | Description | | ------------- | ----------------------------- | ------- | ----------------------------------------------------------------- | | `children` | `ReactNode` | - | The item content. This is used as the `label` for the option. | | `value` | `string` | - | The unique value of the item. | | `onSelect` | `(value: OptionType) => void` | - | Callback when item is selected, receiving the full option object. | | `disabled` | `boolean` | `false` | If true, the item cannot be selected. | | `searchValue` | `string` | - | A custom string to use for search filtering instead of the label. | | `style` | `ViewStyle` | - | Additional styles for the item. | # Date Picker > A customizable date and time picker component with bottom sheet UI. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/date-picker - Markdown: https://ui.ahmedbna.com/docs/components/date-picker.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/date-picker.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/date-picker.json - Install: `npx bna-ui add date-picker` - npm dependencies: `expo-haptics`, `lucide-react-native`, `react-native-gesture-handler`, `react-native-reanimated`, `react-native-safe-area-context`, `react-native-svg`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `useHaptics`, `globals`, `useKeyboardHeight`, `text`, `view`, `bottom-sheet`, `icon`, `spinner`, `button`, `scroll-view` - Preview recording: https://demo.ahmedbna.com/0130-date-picker-demo.MP4 --- **Example:** A basic date picker with calendar view ```tsx // components/demo/date-picker/date-picker-demo.tsx import { DatePicker } from '@/components/ui/date-picker'; import React, { useState } from 'react'; export function DatePickerDemo() { const [selectedDate, setSelectedDate] = useState(); return ( ); } ``` ## Installation ### CLI ```bash npx bna-ui add date-picker ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install lucide-react-native ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/date-picker.tsx import { BottomSheet, useBottomSheet } from '@/components/ui/bottom-sheet'; import { Button } from '@/components/ui/button'; import { Icon } from '@/components/ui/icon'; import { ScrollView } from '@/components/ui/scroll-view'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { useHaptics } from '@/hooks/useHaptics'; import { BORDER_RADIUS, CORNERS, FONT_SIZE, HEIGHT } from '@/theme/globals'; import { Calendar, CalendarClock, ChevronDown, ChevronLeft, ChevronRight, Clock, CalendarRange, ArrowRight, } from 'lucide-react-native'; import { useCallback, useMemo, useState } from 'react'; import { TextStyle, TouchableOpacity, ViewStyle } from 'react-native'; export interface DateRange { startDate: Date | null; endDate: Date | null; } // Conditional typing based on mode interface BaseDatePickerProps { label?: string; error?: string; placeholder?: string; disabled?: boolean; style?: ViewStyle; minimumDate?: Date; maximumDate?: Date; timeFormat?: '12' | '24'; variant?: 'filled' | 'outline' | 'group'; labelStyle?: TextStyle; errorStyle?: TextStyle; haptic?: boolean; } interface DatePickerPropsRange extends BaseDatePickerProps { mode: 'range'; value?: DateRange; onChange?: (value: DateRange | undefined) => void; } interface DatePickerPropsDate extends BaseDatePickerProps { mode?: 'date' | 'time' | 'datetime'; value?: Date; onChange?: (value: Date | undefined) => void; } export type DatePickerProps = DatePickerPropsRange | DatePickerPropsDate; const MONTHS = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', ]; const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; // Generate year range (current year ± 50 years) const currentYear = new Date().getFullYear(); const YEARS = Array.from({ length: 101 }, (_, i) => currentYear - 50 + i); // Type guard to check if value is DateRange const isDateRange = ( value: Date | DateRange | undefined ): value is DateRange => { return ( value !== undefined && typeof value === 'object' && value !== null && 'startDate' in value && 'endDate' in value ); }; export function DatePicker(props: DatePickerProps) { const { label, error, placeholder = 'Select date', disabled = false, style, minimumDate, maximumDate, timeFormat = '24', variant = 'filled', labelStyle, errorStyle, haptic = true, } = props; const mode = props.mode || 'date'; const value = props.value; const onChange = props.onChange; const feedback = useHaptics(haptic); const { isVisible, open, close } = useBottomSheet(); // Get the current date for navigation, prioritizing single date or range start date const getCurrentDate = useCallback(() => { if (mode === 'range') { const rangeValue = isDateRange(value) ? value : { startDate: null, endDate: null }; return rangeValue.startDate || new Date(); } return (value as Date) || new Date(); }, [value, mode]); const [currentDate, setCurrentDate] = useState(() => getCurrentDate()); const [viewMode, setViewMode] = useState<'date' | 'time' | 'month' | 'year'>( 'date' ); const [showMonthPicker, setShowMonthPicker] = useState(false); const [showYearPicker, setShowYearPicker] = useState(false); // Range selection state for temporary storage during selection const [tempRange, setTempRange] = useState(() => mode === 'range' && isDateRange(value) ? value : { startDate: null, endDate: null } ); // Theme colors const cardColor = useColor('card'); const borderColor = useColor('border'); const primaryColor = useColor('primary'); const primaryForegroundColor = useColor('primaryForeground'); const mutedColor = useColor('muted'); const textMutedColor = useColor('textMuted'); const mutedForegroundColor = useColor('mutedForeground'); const textColor = useColor('text'); const errorColor = useColor('red'); const formatDisplayValue = useCallback(() => { if (mode === 'range') { const rangeValue = isDateRange(value) ? value : { startDate: null, endDate: null }; if (!rangeValue.startDate && !rangeValue.endDate) { return placeholder; } const startStr = rangeValue.startDate ? rangeValue.startDate.toLocaleDateString() : ''; const endStr = rangeValue.endDate ? rangeValue.endDate.toLocaleDateString() : ''; if (startStr && endStr) { return `${startStr} - ${endStr}`; } else if (startStr) { return `${startStr} - Select end date`; } else if (endStr) { return `Select start date - ${endStr}`; } return placeholder; } const dateValue = value as Date; if (!dateValue) return placeholder; switch (mode) { case 'time': if (timeFormat === '12') { return dateValue.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: true, }); } return dateValue.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false, }); case 'datetime': const timeStr = timeFormat === '12' ? dateValue.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: true, }) : dateValue.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false, }); return `${dateValue.toLocaleDateString()} ${timeStr}`; default: return dateValue.toLocaleDateString(); } }, [value, mode, placeholder, timeFormat]); // Helper function to check if a date is disabled const isDateDisabled = useCallback( (date: Date) => { if (minimumDate && date < minimumDate) return true; if (maximumDate && date > maximumDate) return true; return false; }, [minimumDate, maximumDate] ); // Helper function to check if a date is in range const isDateInRange = useCallback( (date: Date) => { if (mode !== 'range' || !tempRange.startDate || !tempRange.endDate) { return false; } // Create new date objects to avoid mutation const startDate = new Date(tempRange.startDate); const endDate = new Date(tempRange.endDate); const checkDate = new Date(date); // Normalize dates for comparison (remove time) startDate.setHours(0, 0, 0, 0); endDate.setHours(0, 0, 0, 0); checkDate.setHours(0, 0, 0, 0); return checkDate >= startDate && checkDate <= endDate; }, [mode, tempRange] ); // Helper function to check if a date is a range endpoint const isRangeEndpoint = useCallback( (date: Date) => { if (mode !== 'range') { return { isStart: false, isEnd: false }; } const normalizedDate = new Date(date); normalizedDate.setHours(0, 0, 0, 0); const isStart = tempRange.startDate && new Date(tempRange.startDate).setHours(0, 0, 0, 0) === normalizedDate.getTime(); const isEnd = tempRange.endDate && new Date(tempRange.endDate).setHours(0, 0, 0, 0) === normalizedDate.getTime(); return { isStart: !!isStart, isEnd: !!isEnd }; }, [mode, tempRange] ); // Memoized calendar calculations const calendarData = useMemo(() => { const year = currentDate.getFullYear(); const month = currentDate.getMonth(); // Get first day of month and number of days const firstDay = new Date(year, month, 1).getDay(); const daysInMonth = new Date(year, month + 1, 0).getDate(); // Create calendar grid with proper positioning const weeks: (number | null)[][] = []; let currentWeek: (number | null)[] = []; // Fill empty cells for days before month starts for (let i = 0; i < firstDay; i++) { currentWeek.push(null); } // Add days of the month for (let day = 1; day <= daysInMonth; day++) { currentWeek.push(day); // If week is complete (7 days) or it's the last day, start a new week if (currentWeek.length === 7) { weeks.push([...currentWeek]); currentWeek = []; } } // Add the last incomplete week if it exists if (currentWeek.length > 0) { // Fill remaining cells with null while (currentWeek.length < 7) { currentWeek.push(null); } weeks.push(currentWeek); } return { weeks, year, month, daysInMonth }; }, [currentDate]); const handleRangeSelect = (day: number) => { const selectedDate = new Date( currentDate.getFullYear(), currentDate.getMonth(), day ); // Check if date is disabled if (isDateDisabled(selectedDate)) return; feedback('selection'); // If no start date or both dates are selected, start fresh if (!tempRange.startDate || (tempRange.startDate && tempRange.endDate)) { setTempRange({ startDate: selectedDate, endDate: null, }); } else { // We have a start date but no end date const startDate = tempRange.startDate; if (selectedDate < startDate) { // If selected date is before start date, make it the new start date setTempRange({ startDate: selectedDate, endDate: null, }); } else { // Selected date is after start date, make it the end date setTempRange({ startDate: startDate, endDate: selectedDate, }); } } }; const handleDateSelect = (day: number) => { if (mode === 'range') { handleRangeSelect(day); return; } const newDate = new Date( currentDate.getFullYear(), currentDate.getMonth(), day ); // Check if date is disabled if (isDateDisabled(newDate)) return; // Range mode returns above, so this only fires for the leaf case and never // doubles up with handleRangeSelect. feedback('selection'); setCurrentDate(newDate); if (mode === 'date') { (onChange as (value: Date | undefined) => void)?.(newDate); close(); } else if (mode === 'datetime') { setViewMode('time'); } }; const handleTimeChange = (hours: number, minutes: number) => { feedback('tick'); const newDate = new Date(currentDate); newDate.setHours(hours, minutes, 0, 0); setCurrentDate(newDate); }; const navigateMonth = (direction: 'prev' | 'next') => { feedback('tick'); const newDate = new Date(currentDate); if (direction === 'prev') { newDate.setMonth(newDate.getMonth() - 1); } else { newDate.setMonth(newDate.getMonth() + 1); } setCurrentDate(newDate); }; const handleMonthSelect = (monthIndex: number) => { feedback('selection'); const newDate = new Date(currentDate); newDate.setMonth(monthIndex); setCurrentDate(newDate); setShowMonthPicker(false); }; const handleYearSelect = (year: number) => { feedback('selection'); const newDate = new Date(currentDate); newDate.setFullYear(year); setCurrentDate(newDate); setShowYearPicker(false); }; const handleConfirm = () => { feedback('success'); if (mode === 'range') { (onChange as (value: DateRange | undefined) => void)?.(tempRange); } else { (onChange as (value: Date | undefined) => void)?.(currentDate); } close(); }; const resetToToday = () => { const today = new Date(); setCurrentDate(today); if (mode === 'range') { setTempRange({ startDate: today, endDate: null }); } else if (mode === 'date') { (onChange as (value: Date | undefined) => void)?.(today); close(); } }; const clearSelection = () => { if (mode === 'range') { setTempRange({ startDate: null, endDate: null }); (onChange as (value: DateRange | undefined) => void)?.(undefined); } else { (onChange as (value: Date | undefined) => void)?.(undefined); } }; const renderMonthYearHeader = () => ( navigateMonth('prev')} style={{ padding: 10, borderRadius: CORNERS, backgroundColor: mutedColor, }} > setShowMonthPicker(true)} style={{ flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 12, paddingVertical: 10, borderRadius: CORNERS, backgroundColor: mutedColor, }} > {MONTHS[calendarData.month]} setShowYearPicker(true)} style={{ flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', paddingHorizontal: 16, paddingVertical: 10, borderRadius: CORNERS, backgroundColor: mutedColor, }} > {calendarData.year} navigateMonth('next')} style={{ padding: 10, borderRadius: CORNERS, backgroundColor: mutedColor, }} > ); const renderCalendar = () => ( {renderMonthYearHeader()} {/* Day headers */} {DAYS.map((day) => ( {day} ))} {/* Calendar grid */} {calendarData.weeks.map((week, weekIndex) => ( {week.map((day, dayIndex) => { const dayDate = day ? new Date(calendarData.year, calendarData.month, day) : null; const isSelected = day && value && !isDateRange(value) && value.getDate() === day && value.getMonth() === calendarData.month && value.getFullYear() === calendarData.year; const isToday = day && new Date().getDate() === day && new Date().getMonth() === calendarData.month && new Date().getFullYear() === calendarData.year; const disabled = dayDate ? isDateDisabled(dayDate) : false; // Range-specific styling const inRange = dayDate ? isDateInRange(dayDate) : false; const rangeEndpoints = dayDate ? isRangeEndpoint(dayDate) : { isStart: false, isEnd: false }; return ( {day ? ( !disabled && handleDateSelect(day)} disabled={disabled} style={[ { width: 40, height: 40, borderRadius: rangeEndpoints.isStart || rangeEndpoints.isEnd ? 0 : CORNERS, backgroundColor: rangeEndpoints.isStart || rangeEndpoints.isEnd ? primaryColor : inRange ? primaryColor : isSelected ? primaryColor : 'transparent', borderWidth: isToday && !isSelected && !inRange ? 1 : 0, borderColor: primaryColor, justifyContent: 'center', alignItems: 'center', opacity: disabled ? 0.3 : 1, }, rangeEndpoints.isStart && { borderTopLeftRadius: CORNERS, borderBottomLeftRadius: CORNERS, }, rangeEndpoints.isEnd && { borderTopRightRadius: CORNERS, borderBottomRightRadius: CORNERS, }, ]} > {day} ) : ( )} ); })} ))} {/* Range selection info */} {mode === 'range' && ( {tempRange.startDate ? `${tempRange.startDate.toLocaleDateString()}` : 'Start date'} {tempRange.endDate ? `${tempRange.endDate.toLocaleDateString()}` : 'End date'} )} ); const renderTimePicker = () => { const selectedHours = currentDate.getHours(); const selectedMinutes = currentDate.getMinutes(); const isPM = selectedHours >= 12; return ( {/* Hours */} Hours {Array.from({ length: timeFormat === '12' ? 12 : 24 }, (_, i) => timeFormat === '12' ? (i === 0 ? 12 : i) : i ).map((hour) => { const actualHour = timeFormat === '12' ? hour === 12 ? isPM ? 12 : 0 : isPM ? hour + 12 : hour : hour; const isSelected = actualHour === selectedHours; return ( handleTimeChange(actualHour, selectedMinutes) } style={{ paddingVertical: 12, paddingHorizontal: 16, borderRadius: CORNERS, backgroundColor: isSelected ? primaryColor : 'transparent', marginVertical: 2, alignItems: 'center', }} > {hour.toString().padStart(2, '0')} ); })} {/* Minutes */} Minutes {Array.from({ length: 12 }, (_, i) => i * 5).map((minute) => ( handleTimeChange(selectedHours, minute)} style={{ paddingVertical: 12, paddingHorizontal: 16, borderRadius: CORNERS, backgroundColor: minute === selectedMinutes ? primaryColor : 'transparent', marginVertical: 2, alignItems: 'center', }} > {minute.toString().padStart(2, '0')} ))} {/* AM/PM picker for 12-hour format */} {timeFormat === '12' && ( Period {['AM', 'PM'].map((period) => { const isAM = period === 'AM'; const isSelected = isAM ? !isPM : isPM; return ( { const newHours = isAM ? selectedHours >= 12 ? selectedHours - 12 : selectedHours : selectedHours < 12 ? selectedHours + 12 : selectedHours; handleTimeChange(newHours, selectedMinutes); }} style={{ paddingVertical: 12, paddingHorizontal: 16, borderRadius: CORNERS, backgroundColor: isSelected ? primaryColor : 'transparent', alignItems: 'center', }} > {period} ); })} )} ); }; const renderMonthPicker = () => ( {MONTHS.map((month, index) => ( handleMonthSelect(index)} style={{ paddingVertical: 16, paddingHorizontal: 20, borderRadius: CORNERS, backgroundColor: index === calendarData.month ? primaryColor : 'transparent', marginVertical: 2, alignItems: 'center', }} > {month} ))} ); const renderYearPicker = () => ( {YEARS.map((year) => ( handleYearSelect(year)} style={{ paddingVertical: 16, paddingHorizontal: 20, borderRadius: CORNERS, backgroundColor: year === calendarData.year ? primaryColor : 'transparent', marginVertical: 2, alignItems: 'center', }} > {year} ))} ); const getBottomSheetContent = () => { if (showMonthPicker) return renderMonthPicker(); if (showYearPicker) return renderYearPicker(); if (mode === 'datetime') { return viewMode === 'date' ? renderCalendar() : renderTimePicker(); } if (mode === 'time') return renderTimePicker(); return renderCalendar(); }; const getBottomSheetTitle = () => { if (showMonthPicker) return 'Select Month'; if (showYearPicker) return 'Select Year'; if (mode === 'datetime') { return viewMode === 'date' ? 'Select Date' : 'Select Time'; } if (mode === 'time') return 'Select Time'; if (mode === 'range') return 'Select Range'; return 'Select Date'; }; const handleOpenPicker = () => { feedback('impact-light'); setCurrentDate(new Date()); setViewMode('date'); setShowMonthPicker(false); setShowYearPicker(false); open(); }; const triggerStyle: ViewStyle = { width: '100%', flexDirection: 'row', alignItems: 'center', paddingHorizontal: variant === 'group' ? 0 : 16, borderWidth: variant === 'group' ? 0 : 1, borderColor: variant === 'outline' ? borderColor : cardColor, borderRadius: CORNERS, backgroundColor: variant === 'filled' ? cardColor : 'transparent', minHeight: variant === 'group' ? 'auto' : HEIGHT, }; return ( <> {mode === 'time' ? ( ) : mode === 'datetime' ? ( ) : mode === 'range' ? ( ) : ( )} {/* Label takes 1/3 of available width when present */} {label && ( {label} )} {/* Text takes 2/3 of available width when label is present, or full width when no label */} {formatDisplayValue()} {error && ( {error} )} { close(); setShowMonthPicker(false); setShowYearPicker(false); }} title={getBottomSheetTitle()} snapPoints={[0.7]} disablePanGesture={showMonthPicker || showYearPicker} > {getBottomSheetContent()} {mode === 'datetime' && viewMode === 'date' ? ( ) : ( )} ); } ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { DatePicker } from '@/components/ui/date-picker'; ``` ```tsx const [selectedDate, setSelectedDate] = useState(); ; ``` ## Examples #### Default **Example:** A basic date picker with calendar view ```tsx // components/demo/date-picker/date-picker-demo.tsx import { DatePicker } from '@/components/ui/date-picker'; import React, { useState } from 'react'; export function DatePickerDemo() { const [selectedDate, setSelectedDate] = useState(); return ( ); } ``` #### Time Picker **Example:** A time picker with hour and minute selection ```tsx // components/demo/date-picker/date-picker-time.tsx import { DatePicker } from '@/components/ui/date-picker'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function DatePickerTime() { const [time24, setTime24] = useState(); const [time12, setTime12] = useState(); return ( ); } ``` #### Date Time Picker **Example:** A combined date and time picker ```tsx // components/demo/date-picker/date-picker-datetime.tsx import { DatePicker } from '@/components/ui/date-picker'; import React, { useState } from 'react'; export function DatePickerDateTime() { const [dateTime, setDateTime] = useState(); return ( ); } ``` #### Date Range **Example:** Date picker range ```tsx // components/demo/date-picker/date-picker-range.tsx import { DatePicker, DateRange } from '@/components/ui/date-picker'; import React, { useState } from 'react'; export function DatePickerRange() { const [selectedRange, setSelectedRange] = useState(); return ( ); } ``` #### With Constraints **Example:** Date picker with minimum and maximum date limits ```tsx // components/demo/date-picker/date-picker-constraints.tsx import { DatePicker } from '@/components/ui/date-picker'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function DatePickerConstraints() { const [pastDate, setPastDate] = useState(); const [futureDate, setFutureDate] = useState(); const [rangeDate, setRangeDate] = useState(); const today = new Date(); const maxPastDate = new Date(); maxPastDate.setDate(today.getDate() - 1); // Yesterday const minFutureDate = new Date(); minFutureDate.setDate(today.getDate() + 1); // Tomorrow const minRangeDate = new Date(); minRangeDate.setMonth(today.getMonth() - 1); // Last month const maxRangeDate = new Date(); maxRangeDate.setMonth(today.getMonth() + 1); // Next month return ( ); } ``` #### Different Variants **Example:** Date pickers with different styling variants ```tsx // components/demo/date-picker/date-picker-variants.tsx import { DatePicker } from '@/components/ui/date-picker'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function DatePickerVariants() { const [filledDate, setFilledDate] = useState(); const [outlineDate, setOutlineDate] = useState(); const [groupDate, setGroupDate] = useState(); return ( ); } ``` #### Time Formats **Example:** Time picker with 12-hour and 24-hour formats ```tsx // components/demo/date-picker/date-picker-formats.tsx import { DatePicker } from '@/components/ui/date-picker'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function DatePickerFormats() { const [datetime24, setDateTime24] = useState(); const [datetime12, setDateTime12] = useState(); return ( 24-Hour Format 12-Hour Format with AM/PM ); } ``` #### Form Integration **Example:** Date picker integrated within a form with validation ```tsx // components/demo/date-picker/date-picker-form.tsx import { Button } from '@/components/ui/button'; import { DatePicker } from '@/components/ui/date-picker'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; import { Alert } from 'react-native'; export function DatePickerForm() { const [birthDate, setBirthDate] = useState(); const [appointmentDate, setAppointmentDate] = useState(); const [errors, setErrors] = useState<{ birthDate?: string; appointmentDate?: string; }>({}); const validateForm = () => { const newErrors: typeof errors = {}; if (!birthDate) { newErrors.birthDate = 'Birth date is required'; } else { const today = new Date(); const age = today.getFullYear() - birthDate.getFullYear(); if (age < 18) { newErrors.birthDate = 'Must be 18 years or older'; } } if (!appointmentDate) { newErrors.appointmentDate = 'Appointment date is required'; } else { const today = new Date(); today.setHours(0, 0, 0, 0); if (appointmentDate < today) { newErrors.appointmentDate = 'Appointment must be in the future'; } } setErrors(newErrors); return Object.keys(newErrors).length === 0; }; const handleSubmit = () => { if (validateForm()) { Alert.alert('Success', 'Form submitted successfully!'); } }; const maxBirthDate = new Date(); maxBirthDate.setFullYear(maxBirthDate.getFullYear() - 18); const minAppointmentDate = new Date(); minAppointmentDate.setDate(minAppointmentDate.getDate() + 1); return ( Registration Form { setBirthDate(date); if (errors.birthDate) { setErrors((prev) => ({ ...prev, birthDate: undefined })); } }} placeholder='Select your birth date' maximumDate={maxBirthDate} error={errors.birthDate} /> { setAppointmentDate(date); if (errors.appointmentDate) { setErrors((prev) => ({ ...prev, appointmentDate: undefined })); } }} placeholder='Select appointment date and time' minimumDate={minAppointmentDate} timeFormat='12' error={errors.appointmentDate} /> ); } ``` ## API Reference ### DatePicker The main date picker component that handles date and time selection. | Prop | Type | Default | Description | | ------------- | ------------------------------------------------------------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `haptic` | `boolean` | `true` | Whether to trigger haptic feedback while navigating and selecting — a tick per month or time step, a selection per date, and a success notification on confirm. | | `label` | `string` | - | Label text displayed above the picker. | | `value` | `Date \| DateRange` | - | The currently selected date. `DateRange` (`{ startDate, endDate }`) when `mode` is `"range"`, otherwise a plain `Date`. | | `onChange` | `(value: Date \| undefined) => void \| (value: DateRange \| undefined) => void` | - | Callback fired when the selection changes. Receives a `DateRange \| undefined` when `mode` is `"range"`, otherwise a `Date \| undefined`. | | `mode` | `'date' \| 'range' \| 'time' \| 'datetime'` | `'date'` | The picker mode. `value`/`onChange` switch shape based on this: `DateRange` for `"range"`, `Date` for every other mode. | | `placeholder` | `string` | `'Select date'` | Placeholder text when no date is selected. | | `disabled` | `boolean` | `false` | Whether the picker is disabled. | | `error` | `string` | - | Error message to display. | | `minimumDate` | `Date` | - | The minimum selectable date. | | `maximumDate` | `Date` | - | The maximum selectable date. | | `timeFormat` | `'12' \| '24'` | `'24'` | Time format for time and datetime modes. | | `variant` | `'filled' \| 'outline' \| 'group'` | `'filled'` | Visual variant of the picker trigger. | | `style` | `ViewStyle` | - | Additional styles for the trigger container. | | `labelStyle` | `TextStyle` | - | Additional styles for the label text. | | `errorStyle` | `TextStyle` | - | Additional styles for the error text. | ### DateRange The value shape used when `mode` is `"range"`. | Prop | Type | Description | | ----------- | -------------- | -------------------------------- | | `startDate` | `Date \| null` | The start of the selected range. | | `endDate` | `Date \| null` | The end of the selected range. | ## Features ### Multiple Modes - **Date Mode**: Calendar-based date selection - **Range Mode**: Calendar-based range date selection - **Time Mode**: Hour and minute selection with scrollable lists - **DateTime Mode**: Combined date and time selection with step-by-step flow ### Flexible Time Formats - **24-hour format**: Standard 24-hour time display (00:00 - 23:59) - **12-hour format**: AM/PM time display with period selector ### Date Constraints - Set minimum and maximum selectable dates - Automatic disabling of invalid dates - Visual feedback for disabled dates ### Customizable Variants - **Filled**: Default filled background style - **Outline**: Border-only style with transparent background - **Group**: Minimal style for use within form groups ### Accessibility Features - Full screen reader support - Keyboard navigation compatibility - High contrast mode support - Proper focus management ### Navigation - Month/year quick selection - Smooth navigation between months - Today button for quick access to current date - Intuitive calendar grid layout ## Accessibility The DatePicker component follows accessibility best practices: - Uses semantic markup for screen readers - Provides proper ARIA labels and descriptions - Supports keyboard navigation - Maintains focus management in the bottom sheet - Offers high contrast support for visually impaired users - Includes proper error announcement ## Customization The component uses your theme colors and can be customized through: - Theme color overrides - Custom styling props - Variant selection - Label and error styling - Container styling ## Performance - Optimized calendar calculations with useMemo - Efficient date validation - Minimal re-renders with proper callback optimization - Smooth scrolling in time pickers # File Picker > A customizable file picker component with validation, preview, and multiple file support. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/file-picker - Markdown: https://ui.ahmedbna.com/docs/components/file-picker.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/file-picker.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/file-picker.json - Install: `npx bna-ui add file-picker` - npm dependencies: `expo-document-picker`, `expo-haptics`, `lucide-react-native`, `react-native-reanimated`, `react-native-svg`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `useHaptics`, `text`, `view`, `icon`, `spinner`, `button` - Preview recording: https://demo.ahmedbna.com/0138-file-picker-demo.MP4 --- **Example:** A basic file picker with validation and preview ```tsx // components/demo/file-picker/file-picker-demo.tsx import { FilePicker } from '@/components/ui/file-picker'; import React from 'react'; export function FilePickerDemo() { return ( console.log('Selected files:', files)} onError={(error) => console.error('Error:', error)} fileType='all' multiple={true} maxFiles={5} placeholder='Select your files' showFileInfo={true} /> ); } ``` ## Installation ### CLI ```bash npx bna-ui add file-picker ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install expo-document-picker lucide-react-native ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/file-picker.tsx import { Button, ButtonVariant } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { CORNERS, FONT_SIZE } from '@/theme/globals'; import * as DocumentPicker from 'expo-document-picker'; import { File, Image, X } from 'lucide-react-native'; import React, { forwardRef, useCallback, useMemo, useState } from 'react'; import { ScrollView, StyleSheet, TouchableOpacity, ViewStyle, } from 'react-native'; export type FileType = 'image' | 'document' | 'all'; export interface SelectedFile { uri: string; name: string; type?: string; size?: number; mimeType?: string; } export interface FilePickerProps { // Core functionality onFilesSelected: (files: SelectedFile[]) => void; onError?: (error: string) => void; // Configuration fileType?: FileType; multiple?: boolean; maxFiles?: number; maxSizeBytes?: number; allowedExtensions?: string[]; // UI customization placeholder?: string; disabled?: boolean; style?: ViewStyle; showPreview?: boolean; showFileInfo?: boolean; // Accessibility accessibilityLabel?: string; accessibilityHint?: string; variant?: ButtonVariant; } interface FilePickerMethods { clearFiles: () => void; openPicker: () => void; } export const FilePicker = forwardRef( ( { onFilesSelected, onError, fileType = 'all', multiple = false, maxFiles = 10, maxSizeBytes = 10 * 1024 * 1024, // 10MB default allowedExtensions, placeholder = 'Select files', disabled = false, style = {}, showPreview = true, showFileInfo = true, accessibilityLabel, accessibilityHint, variant = 'outline', }, ref ) => { const [selectedFiles, setSelectedFiles] = useState([]); // Theme colors const backgroundColor = useColor('card'); const borderColor = useColor('border'); const textColor = useColor('text'); const mutedTextColor = useColor('textMuted'); const primaryColor = useColor('primary'); // Expose methods via ref React.useImperativeHandle(ref, () => ({ clearFiles: () => { setSelectedFiles([]); onFilesSelected([]); }, openPicker: () => { handleDocumentPick(); }, })); const validateFile = useCallback( (file: SelectedFile): string | null => { // Size validation if (file.size && file.size > maxSizeBytes) { return `File size exceeds ${(maxSizeBytes / (1024 * 1024)).toFixed( 1 )}MB limit`; } // Extension validation if (allowedExtensions && allowedExtensions.length > 0) { const extension = file.name.split('.').pop()?.toLowerCase(); if (!extension || !allowedExtensions.includes(extension)) { return `File type not allowed. Allowed types: ${allowedExtensions.join( ', ' )}`; } } return null; }, [maxSizeBytes, allowedExtensions] ); const addFiles = useCallback( (newFiles: SelectedFile[]) => { const validFiles: SelectedFile[] = []; const errors: string[] = []; for (const file of newFiles) { const error = validateFile(file); if (error) { errors.push(`${file.name}: ${error}`); } else { validFiles.push(file); } } if (errors.length > 0) { onError?.(errors.join('\n')); } if (validFiles.length > 0) { const updatedFiles = multiple ? [...selectedFiles, ...validFiles].slice(0, maxFiles) : validFiles.slice(0, 1); setSelectedFiles(updatedFiles); onFilesSelected(updatedFiles); if (multiple && selectedFiles.length + validFiles.length > maxFiles) { onError?.(`Only first ${maxFiles} files were selected`); } } }, [ selectedFiles, multiple, maxFiles, validateFile, onFilesSelected, onError, ] ); const removeFile = useCallback( (index: number) => { const updatedFiles = selectedFiles.filter((_, i) => i !== index); setSelectedFiles(updatedFiles); onFilesSelected(updatedFiles); }, [selectedFiles, onFilesSelected] ); const handleDocumentPick = useCallback(async () => { try { const result = await DocumentPicker.getDocumentAsync({ type: fileType === 'image' ? 'image/*' : '*/*', multiple, copyToCacheDirectory: true, }); if (!result.canceled) { const files: SelectedFile[] = result.assets.map((asset) => ({ uri: asset.uri, name: asset.name, size: asset.size, mimeType: asset.mimeType || undefined, })); addFiles(files); } } catch (error) { onError?.(`Failed to pick document: ${error}`); } }, [fileType, multiple, addFiles, onError]); const handlePickerPress = useCallback(() => { if (disabled) return; handleDocumentPick(); }, [disabled, fileType, handleDocumentPick]); const formatFileSize = (bytes: number): string => { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }; const getFileIcon = (fileName: string) => { const extension = fileName.split('.').pop()?.toLowerCase(); if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(extension || '')) { return ; } return ; }; return ( {/* File Picker Button */} {/* Selected Files Preview */} {showPreview && selectedFiles.length > 0 && ( {selectedFiles.map((file, index) => ( {getFileIcon(file.name)} {file.name} {showFileInfo && file.size && ( {formatFileSize(file.size)} )} removeFile(index)} style={styles.removeButton} accessibilityLabel={`Remove ${file.name}`} > ))} )} ); } ); FilePicker.displayName = 'FilePicker'; const styles = StyleSheet.create({ container: { width: '100%', }, pickerButton: { justifyContent: 'flex-start', paddingHorizontal: 16, minHeight: 48, }, buttonContent: { flexDirection: 'row', alignItems: 'center', gap: 12, }, buttonText: { fontSize: FONT_SIZE, fontWeight: '400', }, filesContainer: { marginTop: 12, maxHeight: 300, }, fileItem: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', padding: 12, borderRadius: CORNERS, borderWidth: 1, marginBottom: 8, }, fileInfo: { flexDirection: 'row', alignItems: 'center', flex: 1, gap: 12, }, fileDetails: { flex: 1, }, fileName: { fontSize: FONT_SIZE, fontWeight: '500', }, fileSize: { fontSize: 14, marginTop: 2, }, removeButton: { padding: 4, }, }); // Export utility functions for external use export const createFileFromUri = async ( uri: string, name?: string ): Promise => { return { uri, name: name || uri.split('/').pop() || 'file', }; }; export const validateFiles = ( files: SelectedFile[], options: { maxSize?: number; allowedExtensions?: string[]; maxFiles?: number; } ): { valid: SelectedFile[]; errors: string[] } => { const valid: SelectedFile[] = []; const errors: string[] = []; for (const file of files) { if (options.maxSize && file.size && file.size > options.maxSize) { errors.push(`${file.name}: File too large`); continue; } if (options.allowedExtensions) { const ext = file.name.split('.').pop()?.toLowerCase(); if (!ext || !options.allowedExtensions.includes(ext)) { errors.push(`${file.name}: File type not allowed`); continue; } } valid.push(file); } if (options.maxFiles && valid.length > options.maxFiles) { valid.splice(options.maxFiles); errors.push(`Only first ${options.maxFiles} files selected`); } return { valid, errors }; }; export interface UseFilePickerOptions { maxFiles?: number; maxSizeBytes?: number; allowedExtensions?: string[]; onError?: (error: string) => void; } export interface UseFilePickerReturn { files: SelectedFile[]; addFiles: (newFiles: SelectedFile[]) => void; removeFile: (index: number) => void; clearFiles: () => void; totalSize: number; isValid: boolean; errors: string[]; } export function useFilePicker( options: UseFilePickerOptions = {} ): UseFilePickerReturn { const { maxFiles = 10, maxSizeBytes = 10 * 1024 * 1024, // 10MB default allowedExtensions, onError, } = options; const [files, setFiles] = useState([]); const [errors, setErrors] = useState([]); const validateFile = useCallback( (file: SelectedFile): string | null => { // Check file size if (file.size && file.size > maxSizeBytes) { return `File size exceeds ${(maxSizeBytes / (1024 * 1024)).toFixed( 1 )}MB limit`; } // Check file extension if (allowedExtensions && allowedExtensions.length > 0) { const extension = file.name.split('.').pop()?.toLowerCase(); if (!extension || !allowedExtensions.includes(extension)) { return `File type not allowed. Allowed types: ${allowedExtensions.join( ', ' )}`; } } return null; }, [maxSizeBytes, allowedExtensions] ); const addFiles = useCallback( (newFiles: SelectedFile[]) => { const validFiles: SelectedFile[] = []; const validationErrors: string[] = []; // Validate each file for (const file of newFiles) { const error = validateFile(file); if (error) { validationErrors.push(`${file.name}: ${error}`); } else { validFiles.push(file); } } // Handle validation errors if (validationErrors.length > 0) { setErrors(validationErrors); onError?.(validationErrors.join('\n')); } else { setErrors([]); } // Add valid files if (validFiles.length > 0) { setFiles((prev) => { const combined = [...prev, ...validFiles]; // Check if exceeds max files limit if (combined.length > maxFiles) { const truncated = combined.slice(0, maxFiles); const truncationError = `Only first ${maxFiles} files were selected`; setErrors((prev) => [...prev, truncationError]); onError?.(truncationError); return truncated; } return combined; }); } }, [validateFile, maxFiles, onError] ); const removeFile = useCallback((index: number) => { setFiles((prev) => prev.filter((_, i) => i !== index)); // Clear errors when files are removed setErrors([]); }, []); const clearFiles = useCallback(() => { setFiles([]); setErrors([]); }, []); // Calculate total size of all files const totalSize = useMemo(() => { return files.reduce((sum, file) => sum + (file.size || 0), 0); }, [files]); // Check if current state is valid const isValid = useMemo(() => { return errors.length === 0 && files.length > 0 && files.length <= maxFiles; }, [errors.length, files.length, maxFiles]); return { files, addFiles, removeFile, clearFiles, totalSize, isValid, errors, }; } ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { FilePicker } from '@/components/ui/file-picker'; ``` ```tsx console.log('Selected files:', files)} onError={(error) => console.error('Error:', error)} fileType='all' multiple={true} maxFiles={5} placeholder='Select your files' /> ``` ## Examples #### Default **Example:** A basic file picker with validation and preview ```tsx // components/demo/file-picker/file-picker-demo.tsx import { FilePicker } from '@/components/ui/file-picker'; import React from 'react'; export function FilePickerDemo() { return ( console.log('Selected files:', files)} onError={(error) => console.error('Error:', error)} fileType='all' multiple={true} maxFiles={5} placeholder='Select your files' showFileInfo={true} /> ); } ``` #### Image Only **Example:** File picker configured for images only ```tsx // components/demo/file-picker/file-picker-images.tsx import { FilePicker, SelectedFile } from '@/components/ui/file-picker'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function FilePickerImages() { const [selectedFiles, setSelectedFiles] = useState([]); return ( console.error('Error:', error)} fileType='image' multiple={true} maxFiles={3} maxSizeBytes={5 * 1024 * 1024} // 5MB allowedExtensions={['jpg', 'jpeg', 'png', 'gif', 'webp']} placeholder='Select images (max 3)' showFileInfo={true} /> {selectedFiles.length > 0 && ( {selectedFiles.length} image{selectedFiles.length > 1 ? 's' : ''}{' '} selected )} ); } ``` #### Single File **Example:** File picker for selecting a single file ```tsx // components/demo/file-picker/file-picker-single.tsx import { FilePicker, SelectedFile } from '@/components/ui/file-picker'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function FilePickerSingle() { const [selectedFile, setSelectedFile] = useState([]); return ( console.error('Error:', error)} fileType='document' multiple={false} maxFiles={1} maxSizeBytes={2 * 1024 * 1024} // 2MB placeholder='Select a document' showFileInfo={true} /> {selectedFile.length > 0 && ( Selected File: {selectedFile[0].name} {selectedFile[0].size && ( {(selectedFile[0].size / 1024).toFixed(1)} KB )} )} ); } ``` #### With Validation **Example:** File picker with size limits and extension validation ```tsx // components/demo/file-picker/file-picker-validation.tsx import { FilePicker, SelectedFile } from '@/components/ui/file-picker'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function FilePickerValidation() { const [error, setError] = useState(''); const [files, setFiles] = useState([]); return ( { setFiles(files); setError(''); }} onError={setError} fileType='all' multiple={true} maxFiles={2} maxSizeBytes={1 * 1024 * 1024} // 1MB limit allowedExtensions={['pdf', 'doc', 'docx', 'txt']} placeholder='Select (PDF, DOC, DOCX, TXT only)' showFileInfo={true} /> {error && ( {error} )} {files.length > 0 && !error && ( ✓ Files validated successfully {files.length} file{files.length > 1 ? 's' : ''} ready for upload )} ); } ``` #### Custom Styling **Example:** File picker with custom styling and colors ```tsx // components/demo/file-picker/file-picker-styled.tsx import { FilePicker } from '@/components/ui/file-picker'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function FilePickerStyled() { return ( {/* Primary Style */} Primary Style console.log('Primary files:', files)} onError={(error) => console.error('Error:', error)} fileType='all' multiple={true} maxFiles={3} placeholder='Upload files' style={{ borderWidth: 2, borderColor: '#007AFF', borderRadius: 12, // backgroundColor: '#f0f8ff', }} /> {/* Minimal Style */} Minimal Style console.log('Minimal files:', files)} onError={(error) => console.error('Error:', error)} fileType='image' multiple={false} maxFiles={1} placeholder='Choose image' style={{ borderWidth: 1, borderStyle: 'dashed', borderColor: '#ccc', borderRadius: 8, backgroundColor: 'transparent', }} /> {/* Success Style */} Success Style console.log('Success files:', files)} onError={(error) => console.error('Error:', error)} fileType='document' multiple={true} maxFiles={5} placeholder='Select documents' style={{ borderWidth: 2, borderColor: '#34C759', borderRadius: 16, // backgroundColor: '#f0fff4', }} /> ); } ``` #### Controlled **Example:** Controlled file picker using the useFilePicker hook ```tsx // components/demo/file-picker/file-picker-controlled.tsx import { Button } from '@/components/ui/button'; import { useFilePicker } from '@/components/ui/file-picker'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function FilePickerControlled() { const { files, addFiles, removeFile, clearFiles, totalSize, isValid, errors, } = useFilePicker({ maxFiles: 3, maxSizeBytes: 2 * 1024 * 1024, // 2MB allowedExtensions: ['pdf', 'jpg', 'png', 'doc'], onError: (error) => console.error('Validation error:', error), }); const formatSize = (bytes: number) => { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }; // Simulate adding files (in real app, this would come from file picker) const simulateAddFiles = () => { const mockFiles = [ { uri: 'file://test1.pdf', name: 'test1.pdf', size: 150000 }, { uri: 'file://test2.jpg', name: 'test2.jpg', size: 250000 }, ]; addFiles(mockFiles); }; return ( {/* Status Info */} Status Files: {files.length}/3 Total Size: {formatSize(totalSize)} Valid: {isValid ? '✓' : '✗'} {/* Errors */} {errors.length > 0 && ( Errors: {errors.map((error, index) => ( • {error} ))} )} {/* Files List */} {files.length > 0 && ( Selected Files: {files.map((file, index) => ( {file.name} {file.size && ( {formatSize(file.size)} )} ))} )} ); } ``` #### With File Info **Example:** File picker displaying detailed file information ```tsx // components/demo/file-picker/file-picker-info.tsx import { FilePicker, SelectedFile } from '@/components/ui/file-picker'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import React, { useState } from 'react'; export function FilePickerInfo() { const card = useColor('card'); const [files, setFiles] = useState([]); const [uploadProgress, setUploadProgress] = useState({}); const handleFilesSelected = (selectedFiles: SelectedFile[]) => { setFiles(selectedFiles); // Simulate upload progress selectedFiles.forEach((file, index) => { let progress = 0; const interval = setInterval(() => { progress += Math.random() * 20; if (progress >= 100) { progress = 100; clearInterval(interval); } setUploadProgress((prev: any) => ({ ...prev, [index]: Math.min(progress, 100), })); }, 200); }); }; const formatFileSize = (bytes: number) => { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }; const getFileTypeIcon = (fileName: string) => { const ext = fileName.split('.').pop()?.toLowerCase() || ''; const typeMap: Record = { pdf: '📄', doc: '📝', docx: '📝', txt: '📄', jpg: '🖼️', jpeg: '🖼️', png: '🖼️', gif: '🖼️', zip: '📦', rar: '📦', mp4: '🎥', mp3: '🎵', }; return typeMap[ext] || '📎'; }; return ( console.error('Error:', error)} fileType='all' multiple={true} maxFiles={4} maxSizeBytes={5 * 1024 * 1024} // 5MB placeholder='Select files for detailed preview' showFileInfo={true} /> {files.length > 0 && ( File Details {files.map((file, index) => ( {getFileTypeIcon(file.name)} {file.name} {file.size && ( Size: {formatFileSize(file.size)} )} {file.mimeType && ( Type: {file.mimeType} )} Status:{' '} {uploadProgress[index] >= 100 ? 'Uploaded' : 'Uploading...'} {/* Progress Bar */} {uploadProgress[index] !== undefined && ( = 100 ? '#4CAF50' : '#2196F3', borderRadius: 2, }} /> {Math.round(uploadProgress[index] || 0)}% )} ))} {/* Summary */} Summary {files.length} file{files.length > 1 ? 's' : ''} • Total size:{' '} {formatFileSize( files.reduce((sum, file) => sum + (file.size || 0), 0) )} )} ); } ``` ## API Reference ### FilePicker The main file picker component. | Prop | Type | Default | Description | | --------------------- | --------------------------------- | ---------------- | ------------------------------------------ | | `onFilesSelected` | `(files: SelectedFile[]) => void` | - | Callback when files are selected. | | `onError?` | `(error: string) => void` | - | Callback when an error occurs. | | `fileType?` | `'image' \| 'document' \| 'all'` | `'all'` | Type of files to allow. | | `multiple?` | `boolean` | `false` | Whether to allow multiple file selection. | | `maxFiles?` | `number` | `10` | Maximum number of files to select. | | `maxSizeBytes?` | `number` | `10MB` | Maximum file size in bytes. | | `allowedExtensions?` | `string[]` | - | Array of allowed file extensions. | | `placeholder?` | `string` | `'Select files'` | Placeholder text for the picker button. | | `disabled?` | `boolean` | `false` | Whether the picker is disabled. | | `style?` | `ViewStyle` | - | Additional styles for the container. | | `variant?` | `ButtonVariant` | `'outline'` | Visual variant of the picker button. | | `showPreview?` | `boolean` | `true` | Whether to show the selected-files list. | | `showFileInfo?` | `boolean` | `true` | Whether to show file size information. | | `accessibilityLabel?` | `string` | - | Accessibility label for the picker button. | | `accessibilityHint?` | `string` | - | Accessibility hint for the picker button. | ### SelectedFile The file object structure returned by the component. | Property | Type | Description | | ----------- | -------- | ----------------------- | | `uri` | `string` | The file URI. | | `name` | `string` | The file name. | | `type?` | `string` | The file MIME type. | | `size?` | `number` | The file size in bytes. | | `mimeType?` | `string` | The file MIME type. | ### useFilePicker Hook A hook for managing file picker state programmatically. ```tsx const { files, addFiles, removeFile, clearFiles, totalSize, isValid, errors } = useFilePicker({ maxFiles: 5, maxSizeBytes: 5 * 1024 * 1024, // 5MB allowedExtensions: ['pdf', 'doc', 'docx'], onError: (error) => console.error(error), }); ``` #### Options | Property | Type | Default | Description | | -------------------- | ------------------------- | ------- | --------------------------------- | | `maxFiles?` | `number` | `10` | Maximum number of files. | | `maxSizeBytes?` | `number` | `10MB` | Maximum file size in bytes. | | `allowedExtensions?` | `string[]` | - | Array of allowed file extensions. | | `onError?` | `(error: string) => void` | - | Callback when an error occurs. | #### Return Value | Property | Type | Description | | ------------ | --------------------------------- | ----------------------------------- | | `files` | `SelectedFile[]` | Array of selected files. | | `addFiles` | `(files: SelectedFile[]) => void` | Function to add files. | | `removeFile` | `(index: number) => void` | Function to remove a file by index. | | `clearFiles` | `() => void` | Function to clear all files. | | `totalSize` | `number` | Total size of all files in bytes. | | `isValid` | `boolean` | Whether the current state is valid. | | `errors` | `string[]` | Array of validation errors. | ### Utility Functions #### createFileFromUri ```tsx const file = await createFileFromUri(uri, 'custom-name.pdf'); ``` #### validateFiles ```tsx const { valid, errors } = validateFiles(files, { maxSize: 5 * 1024 * 1024, allowedExtensions: ['pdf', 'doc'], maxFiles: 3, }); ``` ## File Types The component supports three file type modes: - `'all'` - All file types (default) - `'image'` - Images only (jpg, jpeg, png, gif, webp, etc.) - `'document'` - All file types with document picker ## Validation The FilePicker includes built-in validation for: - **File size** - Configurable maximum size per file - **File extensions** - Whitelist of allowed extensions - **File count** - Maximum number of files - **MIME types** - Automatic validation based on file type ## Accessibility The FilePicker component is built with accessibility in mind: - Proper accessibility labels and hints - Screen reader support for file information - Keyboard navigation support - Clear error messaging - Semantic button structure ## Best Practices 1. **Set appropriate file size limits** to prevent memory issues 2. **Use specific file type restrictions** when possible 3. **Provide clear error messages** to guide users 4. **Show file previews** when relevant 5. **Handle loading states** for better UX 6. **Validate files** on both client and server side # Gallery > A responsive image gallery component with fullscreen viewing, zoom, and gesture support. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/gallery - Markdown: https://ui.ahmedbna.com/docs/components/gallery.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/gallery.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/gallery.json - Install: `npx bna-ui add gallery` - npm dependencies: `expo-haptics`, `expo-image`, `lucide-react-native`, `react-native-gesture-handler`, `react-native-reanimated`, `react-native-safe-area-context`, `react-native-svg`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `useHaptics`, `text`, `view`, `icon`, `spinner`, `button` - Preview recording: https://demo.ahmedbna.com/0145-gallery-demo.MP4 --- **Example:** A basic image gallery with grid layout and fullscreen viewing ```tsx // components/demo/gallery/gallery-demo.tsx import { Gallery, GalleryItem } from '@/components/ui/gallery'; import React from 'react'; const sampleImages: GalleryItem[] = [ { id: '1', uri: 'https://images.unsplash.com/photo-1637858868799-7f26a0640eb6?q=80&w=2960&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'City Skyline', description: 'Modern architecture at sunset', thumbnail: 'https://images.unsplash.com/photo-1637858868799-7f26a0640eb6?q=80&w=2960&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, { id: '2', uri: 'https://images.unsplash.com/photo-1644190022446-04b99df7259a?q=80&w=2012&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'Winter Wonderland', description: 'Snow-covered peaks and pristine wilderness', thumbnail: 'https://images.unsplash.com/photo-1644190022446-04b99df7259a?q=80&w=2012&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, { id: '3', uri: 'https://images.unsplash.com/photo-1717732596477-04f8c5d53387?q=80&w=987&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'Ocean Waves', description: 'Peaceful ocean scene with rolling waves', thumbnail: 'https://images.unsplash.com/photo-1717732596477-04f8c5d53387?q=80&w=987&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, { id: '4', uri: 'https://images.unsplash.com/photo-1575737698350-52e966f924d4?q=80&w=2070&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'Forest Path', description: 'A winding path through ancient trees', thumbnail: 'https://images.unsplash.com/photo-1575737698350-52e966f924d4?q=80&w=2070&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, { id: '5', uri: 'https://images.unsplash.com/photo-1667830867718-da7f5a45d20d?q=80&w=1064&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'Desert Dunes', description: 'Golden sand dunes stretching to the horizon', thumbnail: 'https://images.unsplash.com/photo-1667830867718-da7f5a45d20d?q=80&w=1064&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, { id: '6', uri: 'https://images.unsplash.com/photo-1593085512500-5d55148d6f0d?q=80&w=2334&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'Beautiful Landscape', description: 'A stunning view of mountains and valleys', thumbnail: 'https://images.unsplash.com/photo-1593085512500-5d55148d6f0d?q=80&w=2334&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, ]; export function GalleryDemo() { return ( ); } ``` ## Installation ### CLI ```bash npx bna-ui add gallery ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install expo-image react-native-gesture-handler react-native-reanimated lucide-react-native ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/gallery.tsx import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { useColor } from '@/hooks/useColor'; import { BORDER_RADIUS } from '@/theme/globals'; import { Image } from 'expo-image'; import { Download, Share, X } from 'lucide-react-native'; import { memo, useCallback, useEffect, useRef, useState } from 'react'; import { FlatList, Modal, Pressable, StyleSheet, useWindowDimensions, View, } from 'react-native'; import { Gesture, GestureDetector, GestureHandlerRootView, } from 'react-native-gesture-handler'; import Animated, { runOnJS, useAnimatedStyle, useSharedValue, withSpring, } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; export interface GalleryItem { id: string; uri: string; title?: string; description?: string; thumbnail?: string; } interface GalleryProps { items: GalleryItem[]; columns?: number; spacing?: number; borderRadius?: number; aspectRatio?: number; showPages?: boolean; showTitles?: boolean; showDescriptions?: boolean; enableFullscreen?: boolean; enableZoom?: boolean; enableDownload?: boolean; enableShare?: boolean; onItemPress?: (item: GalleryItem, index: number) => void; onDownload?: (item: GalleryItem) => void; onShare?: (item: GalleryItem) => void; renderCustomOverlay?: (item: GalleryItem, index: number) => React.ReactNode; } const AnimatedImage = Animated.createAnimatedComponent(Image); // Improved zoom hook with better gesture handling interface UseImageZoomProps { enableZoom: boolean; onSetCanSwipe: (canSwipe: boolean) => void; shouldReset?: boolean; // Indicates if the current image has changed and zoom should reset screenWidth: number; screenHeight: number; } export const useImageZoom = ({ enableZoom, onSetCanSwipe, shouldReset = false, screenWidth, screenHeight, }: UseImageZoomProps) => { // Shared values for animated properties const scale = useSharedValue(1); const translateX = useSharedValue(0); const translateY = useSharedValue(0); // Saved values to store the state at the start of a gesture const savedScale = useSharedValue(1); const savedTranslateX = useSharedValue(0); const savedTranslateY = useSharedValue(0); // Shared value to dynamically enable/disable the pan gesture for dragging const panGestureEnabled = useSharedValue(false); // Initially disabled // Minimum and maximum zoom scale const minScale = 0.8; const maxScale = 4; // Function to reset the image to its initial state (no zoom, no translation) const resetZoom = useCallback(() => { 'worklet'; // Marks this function to run on the UI thread scale.value = withSpring(1, { damping: 20, stiffness: 300 }); translateX.value = withSpring(0, { damping: 20, stiffness: 300 }); translateY.value = withSpring(0, { damping: 20, stiffness: 300 }); savedScale.value = 1; savedTranslateX.value = 0; savedTranslateY.value = 0; // Allow the parent FlatList to swipe when the image is reset runOnJS(onSetCanSwipe)(true); panGestureEnabled.value = false; // Disable pan gesture when reset }, [ scale, translateX, translateY, savedScale, savedTranslateX, savedTranslateY, onSetCanSwipe, panGestureEnabled, ]); // Effect to reset zoom when the `shouldReset` prop changes (meaning a new image is selected) useEffect(() => { if (shouldReset) { resetZoom(); } }, [shouldReset, resetZoom]); // Function to constrain the image translation within its bounds const constrainTranslation = useCallback( (newScale: number, newTranslateX: number, newTranslateY: number) => { 'worklet'; // Calculate maximum allowed translation based on current scale const maxTranslateX = Math.max( 0, (screenWidth * newScale - screenWidth) / 2 ); const maxTranslateY = Math.max( 0, (screenHeight * newScale - screenHeight) / 2 ); // Constrain the new translation values const constrainedX = Math.max( -maxTranslateX, Math.min(maxTranslateX, newTranslateX) ); const constrainedY = Math.max( -maxTranslateY, Math.min(maxTranslateY, newTranslateY) ); return { x: constrainedX, y: constrainedY }; }, [screenWidth, screenHeight] ); // Gesture for double-tapping to zoom in/out const doubleTapGesture = Gesture.Tap() .numberOfTaps(2) .onEnd((event) => { if (!enableZoom) return; // Only process if zoom is enabled ('worklet'); // If already zoomed in (beyond a small threshold), reset to original size if (scale.value > 1.1) { resetZoom(); // This will handle setting panGestureEnabled and canSwipe } else { // Otherwise, zoom to a target scale (e.g., 2.5x) const targetScale = 2.5; // Calculate tap position relative to the center of the screen const tapX = event.x - screenWidth / 2; const tapY = event.y - screenHeight / 2; // Calculate new translation to make the tapped point the new center const newTranslateX = (-tapX * (targetScale - 1)) / targetScale; const newTranslateY = (-tapY * (targetScale - 1)) / targetScale; // Constrain translation to keep image within bounds const constrained = constrainTranslation( targetScale, newTranslateX, newTranslateY ); // Animate scale and translation scale.value = withSpring(targetScale, { damping: 20, stiffness: 300 }); translateX.value = withSpring(constrained.x, { damping: 20, stiffness: 300, }); translateY.value = withSpring(constrained.y, { damping: 20, stiffness: 300, }); // Save current state savedScale.value = targetScale; savedTranslateX.value = constrained.x; savedTranslateY.value = constrained.y; // Disable parent FlatList swiping as image is now zoomed runOnJS(onSetCanSwipe)(false); panGestureEnabled.value = true; // Enable pan gesture for dragging zoomed image } }); // Gesture for pinching to zoom const pinchGesture = Gesture.Pinch() .onStart(() => { if (!enableZoom) return; ('worklet'); // Save current state at the start of the pinch savedScale.value = scale.value; savedTranslateX.value = translateX.value; savedTranslateY.value = translateY.value; }) .onUpdate((event) => { if (!enableZoom) return; ('worklet'); // Calculate new scale, clamping it between min and max const newScale = Math.max( minScale, Math.min(maxScale, savedScale.value * event.scale) ); // Calculate focal point relative to the image center const focalX = event.focalX - screenWidth / 2; const focalY = event.focalY - screenHeight / 2; // Calculate new translation to keep the focal point in place during zoom const scaleDiff = newScale / savedScale.value; const newTranslateX = savedTranslateX.value + focalX * (1 - scaleDiff); const newTranslateY = savedTranslateY.value + focalY * (1 - scaleDiff); // Constrain translation const constrained = constrainTranslation( newScale, newTranslateX, newTranslateY ); // Apply new scale and translation scale.value = newScale; translateX.value = constrained.x; translateY.value = constrained.y; // Dynamically enable/disable panGestureEnabled and FlatList scrolling based on zoom level panGestureEnabled.value = newScale > 1.1; runOnJS(onSetCanSwipe)(newScale <= 1.1); // FlatList scrollable if not zoomed }) .onEnd(() => { if (!enableZoom) return; ('worklet'); // If zoomed out too much, reset to original size if (scale.value < 1) { resetZoom(); // This will handle setting panGestureEnabled and canSwipe } else { // Save current state after pinch ends savedScale.value = scale.value; savedTranslateX.value = translateX.value; savedTranslateY.value = translateY.value; // Re-evaluate if panGestureEnabled and FlatList should be able to swipe panGestureEnabled.value = scale.value > 1.1; runOnJS(onSetCanSwipe)(scale.value <= 1.1); } }); // Gesture for panning (dragging) the image when zoomed in const panGesture = Gesture.Pan() .minPointers(1) // This gesture will respond to a single finger .maxPointers(1) .enabled(panGestureEnabled.value) // Only enabled if panGestureEnabled.value is true .onStart(() => { 'worklet'; // If this onStart is called, it means the gesture is enabled and recognized. savedTranslateX.value = translateX.value; savedTranslateY.value = translateY.value; runOnJS(onSetCanSwipe)(false); // Disable parent FlatList swipe when dragging zoomed image }) .onUpdate((event) => { 'worklet'; // This check is a safeguard, but 'enabled' should prevent this from being called if not zoomed. if (!enableZoom || !panGestureEnabled.value) return; // Calculate new translation based on drag const newTranslateX = savedTranslateX.value + event.translationX; const newTranslateY = savedTranslateY.value + event.translationY; // Constrain translation const constrained = constrainTranslation( scale.value, newTranslateX, newTranslateY ); // Apply new translation translateX.value = constrained.x; translateY.value = constrained.y; }) .onEnd(() => { 'worklet'; savedTranslateX.value = translateX.value; savedTranslateY.value = translateY.value; // Re-enable FlatList swipe if not zoomed after pan ends runOnJS(onSetCanSwipe)(scale.value <= 1.1); }); // Compose all gestures: // - Race: Double tap takes precedence if detected. // - Simultaneous: Pinch and dynamically enabled single-finger pan can happen at the same time. const composedGesture = Gesture.Race( doubleTapGesture, Gesture.Simultaneous(pinchGesture, panGesture) ); // Animated style for the image based on scale and translation values const animatedImageStyle = useAnimatedStyle(() => { return { transform: [ { scale: scale.value }, { translateX: translateX.value }, { translateY: translateY.value }, ], }; }); return { animatedImageStyle, composedGesture, resetZoom, }; }; // Fixed fullscreen image component interface FullscreenImageProps { item: GalleryItem; index: number; selectedIndex: number; enableZoom: boolean; // Callback to inform the parent FlatList whether it should be scrollable onSetCanSwipe: (canSwipe: boolean) => void; screenWidth: number; screenHeight: number; } const FullscreenImage = memo( ({ item, index, selectedIndex, enableZoom, onSetCanSwipe, screenWidth, screenHeight, }: FullscreenImageProps) => { // Determine if this image is the currently selected one to trigger zoom reset const shouldReset = index === selectedIndex; const backgroundColor = useColor('background'); const { animatedImageStyle, composedGesture } = useImageZoom({ enableZoom, onSetCanSwipe, // Pass the callback to the hook shouldReset, screenWidth, screenHeight, }); return ( {/* GestureDetector always present if zoom is enabled */} {enableZoom ? ( ) : ( // If zoom is not enabled, render without GestureDetector )} ); } ); export function Gallery({ items, columns = 4, spacing = 0, aspectRatio = 1, borderRadius = 0, showPages = false, showTitles = false, showDescriptions = false, enableFullscreen = true, enableZoom = true, enableDownload = false, enableShare = false, onItemPress, onDownload, onShare, renderCustomOverlay, }: GalleryProps) { const { width: screenWidth, height: screenHeight } = useWindowDimensions(); const insets = useSafeAreaInsets(); // State for the currently selected image index in fullscreen mode const [selectedIndex, setSelectedIndex] = useState(-1); // State to control modal visibility const [isModalVisible, setIsModalVisible] = useState(false); // State for the calculated width of the gallery container const [containerWidth, setContainerWidth] = useState(screenWidth); // State to control whether the fullscreen FlatList can be swiped horizontally const [flatListScrollEnabled, setFlatListScrollEnabled] = useState(true); // Refs for the FlatList components const fullscreenFlatListRef = useRef(null); const thumbnailFlatListRef = useRef(null); // Theme colors using custom hook const textColor = useColor('text'); const primary = useColor('primary'); const mutedColor = useColor('textMuted'); const backgroundColor = useColor('background'); const secondary = useColor('secondary'); // Calculate item width for the grid based on container width, columns, and spacing const itemWidth = (containerWidth - spacing * (columns - 1)) / columns; // Function to open the fullscreen modal const openFullscreen = useCallback( (index: number) => { if (!enableFullscreen) return; // Only open if fullscreen is enabled setSelectedIndex(index); setIsModalVisible(true); // Initially, allow FlatList scrolling setFlatListScrollEnabled(true); // Use setTimeout to ensure the modal is fully rendered before trying to scroll the FlatList setTimeout(() => { fullscreenFlatListRef.current?.scrollToIndex({ index, animated: false, }); thumbnailFlatListRef.current?.scrollToIndex({ index, animated: false, viewPosition: 0.5, // Center the thumbnail }); }, 100); }, [enableFullscreen] ); // Function to close the fullscreen modal const closeFullscreen = useCallback(() => { setIsModalVisible(false); setSelectedIndex(-1); // Reset selected index setFlatListScrollEnabled(true); // Ensure scrolling is re-enabled on close }, []); // Handler for pressing a gallery item (thumbnail) const handleItemPress = useCallback( (item: GalleryItem, index: number) => { if (onItemPress) { onItemPress(item, index); // Call custom press handler if provided } else if (enableFullscreen) { openFullscreen(index); // Otherwise, open fullscreen } }, [onItemPress, enableFullscreen, openFullscreen] ); // Handler for pressing a thumbnail in the fullscreen bottom bar const handleThumbnailPress = useCallback((index: number) => { setSelectedIndex(index); // Update selected index setFlatListScrollEnabled(true); // Always allow swiping when a thumbnail is tapped fullscreenFlatListRef.current?.scrollToIndex({ index, animated: true, }); }, []); // Callback for FlatList to detect when viewable items change (for updating selected index) const onViewableItemsChanged = useCallback( ({ viewableItems }: any) => { if (viewableItems.length > 0) { const newIndex = viewableItems[0].index; if ( newIndex !== selectedIndex && newIndex !== null && newIndex !== undefined ) { setSelectedIndex(newIndex); // Sync thumbnail scroll to the newly selected image setTimeout(() => { thumbnailFlatListRef.current?.scrollToIndex({ index: newIndex, animated: true, viewPosition: 0.5, // Center the thumbnail }); }, 100); } } }, [selectedIndex] ); // Configuration for viewability of FlatList items const viewabilityConfig = { itemVisiblePercentThreshold: 50, // An item is "viewable" if 50% of it is visible }; // Helper to get the currently displayed item in fullscreen const getCurrentItem = useCallback(() => { return selectedIndex >= 0 && selectedIndex < items.length ? items[selectedIndex] : null; }, [selectedIndex, items]); // Handler for download button const handleDownload = useCallback(() => { const currentItem = getCurrentItem(); if (currentItem && onDownload) { onDownload(currentItem); } }, [getCurrentItem, onDownload]); // Handler for share button const handleShare = useCallback(() => { const currentItem = getCurrentItem(); if (currentItem && onShare) { onShare(currentItem); } }, [getCurrentItem, onShare]); // Render function for each item in the grid gallery const renderGalleryItem = useCallback( ({ item, index }: { item: GalleryItem; index: number }) => ( handleItemPress(item, index)} > {/* Render custom overlay if provided */} {renderCustomOverlay && renderCustomOverlay(item, index)} {/* Display title and description if enabled */} {(showTitles || showDescriptions) && ( {showTitles && item.title && ( {item.title} )} {showDescriptions && item.description && ( {item.description} )} )} ), [ itemWidth, aspectRatio, borderRadius, handleItemPress, renderCustomOverlay, showTitles, showDescriptions, textColor, mutedColor, ] ); // Render function for each item in the fullscreen FlatList const renderFullscreenItem = useCallback( ({ item, index }: { item: GalleryItem; index: number }) => ( ), [enableZoom, selectedIndex, screenWidth, screenHeight] ); // Render controls for the fullscreen modal (top and bottom bars) const renderFullscreenControls = () => { const currentItem = getCurrentItem(); return ( {/* Top controls (share, download, close) */} {enableDownload && onDownload && ( )} {enableShare && onShare && ( )} {/* Bottom controls (page, title, description, thumbnails) */} {showPages && ( {selectedIndex + 1} of {items.length} )} {currentItem?.title && ( {currentItem.title} )} {currentItem?.description && ( {currentItem.description} )} {/* Horizontal FlatList for thumbnails */} ( handleThumbnailPress(index)} > )} keyExtractor={(item) => item.id} horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.thumbnailContainer} ItemSeparatorComponent={() => } // Spacing between thumbnails getItemLayout={(data, index) => ({ length: 48, // Fixed item length for layout calculation offset: 56 * index, // Offset for each item (item length + separator width) index, })} /> ); }; // Render empty state if no items are provided if (items.length === 0) { return ( No images to display ); } return ( // GestureHandlerRootView is required for React Native Gesture Handler to work {/* FlatList for the main gallery grid — the previous plain ScrollView + .map() rendered every item eagerly regardless of scroll position, backwards from the less-visible fullscreen viewer below, which already virtualizes via FlatList. */} item.id} style={[styles.container, { backgroundColor }]} contentContainerStyle={{ gap: spacing }} columnWrapperStyle={columns > 1 ? { gap: spacing } : undefined} showsVerticalScrollIndicator={false} // Measure the container width on layout to calculate item widths dynamically onLayout={(event) => { const { width } = event.nativeEvent.layout; setContainerWidth(width); }} /> {/* Modal for fullscreen image view */} {/* GestureHandlerRootView for gestures within the modal */} {/* FlatList for horizontal swiping of fullscreen images */} item.id} horizontal pagingEnabled // Enables snap-to-page behavior for horizontal swiping showsHorizontalScrollIndicator={false} onViewableItemsChanged={onViewableItemsChanged} // Detect when current image changes viewabilityConfig={viewabilityConfig} getItemLayout={(data, index) => ({ length: screenWidth, // Each item takes full screen width offset: screenWidth * index, index, })} scrollEnabled={flatListScrollEnabled} // Control FlatList scrolling based on zoom state removeClippedSubviews={false} // Important for images that are partially off-screen due to zoom initialNumToRender={3} maxToRenderPerBatch={3} windowSize={21} /> {/* Render fullscreen controls overlay */} {renderFullscreenControls()} ); } // Stylesheet for the component const styles = StyleSheet.create({ container: { flex: 1, }, gridImage: { flex: 1, }, itemInfo: { padding: 8, }, emptyState: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 32, borderRadius: BORDER_RADIUS, margin: 16, }, imageContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', }, fullscreenControls: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, // Ensure controls don't block interaction with the image itself unless explicitly on a button pointerEvents: 'box-none', }, topControls: { position: 'absolute', top: 0, left: 0, right: 0, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 16, paddingBottom: 16, }, topRightControls: { gap: 8, flexDirection: 'row', }, bottomControls: { position: 'absolute', bottom: 0, left: 0, right: 0, padding: 16, }, thumbnailContainer: { paddingHorizontal: 16, alignItems: 'center', // Vertically center thumbnails }, thumbnailItem: { width: 40, height: 40, borderRadius: 8, borderWidth: 1, overflow: 'hidden', borderColor: 'transparent', }, thumbnailImage: { width: '100%', height: '100%', }, }); ``` **3.** Update the import paths to match your project setup. **4.** Make sure to configure react-native-gesture-handler and react-native-reanimated in your app according to their installation guides. ## Usage ```tsx import { Gallery } from '@/components/ui/gallery'; ``` ```tsx const galleryItems = [ { id: '1', uri: 'https://picsum.photos/400/400?random=1', title: 'Beautiful Landscape', description: 'A stunning view of mountains and valleys', }, { id: '2', uri: 'https://picsum.photos/400/400?random=2', title: 'City Skyline', description: 'Urban architecture at its finest', }, // ... more items ]; ; ``` ## Examples #### Default **Example:** A basic image gallery with grid layout and fullscreen viewing ```tsx // components/demo/gallery/gallery-demo.tsx import { Gallery, GalleryItem } from '@/components/ui/gallery'; import React from 'react'; const sampleImages: GalleryItem[] = [ { id: '1', uri: 'https://images.unsplash.com/photo-1637858868799-7f26a0640eb6?q=80&w=2960&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'City Skyline', description: 'Modern architecture at sunset', thumbnail: 'https://images.unsplash.com/photo-1637858868799-7f26a0640eb6?q=80&w=2960&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, { id: '2', uri: 'https://images.unsplash.com/photo-1644190022446-04b99df7259a?q=80&w=2012&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'Winter Wonderland', description: 'Snow-covered peaks and pristine wilderness', thumbnail: 'https://images.unsplash.com/photo-1644190022446-04b99df7259a?q=80&w=2012&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, { id: '3', uri: 'https://images.unsplash.com/photo-1717732596477-04f8c5d53387?q=80&w=987&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'Ocean Waves', description: 'Peaceful ocean scene with rolling waves', thumbnail: 'https://images.unsplash.com/photo-1717732596477-04f8c5d53387?q=80&w=987&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, { id: '4', uri: 'https://images.unsplash.com/photo-1575737698350-52e966f924d4?q=80&w=2070&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'Forest Path', description: 'A winding path through ancient trees', thumbnail: 'https://images.unsplash.com/photo-1575737698350-52e966f924d4?q=80&w=2070&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, { id: '5', uri: 'https://images.unsplash.com/photo-1667830867718-da7f5a45d20d?q=80&w=1064&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'Desert Dunes', description: 'Golden sand dunes stretching to the horizon', thumbnail: 'https://images.unsplash.com/photo-1667830867718-da7f5a45d20d?q=80&w=1064&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, { id: '6', uri: 'https://images.unsplash.com/photo-1593085512500-5d55148d6f0d?q=80&w=2334&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'Beautiful Landscape', description: 'A stunning view of mountains and valleys', thumbnail: 'https://images.unsplash.com/photo-1593085512500-5d55148d6f0d?q=80&w=2334&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, ]; export function GalleryDemo() { return ( ); } ``` #### Custom Grid **Example:** Gallery with custom columns, spacing, and aspect ratio ```tsx // components/demo/gallery/gallery-grid.tsx import { Gallery, GalleryItem } from '@/components/ui/gallery'; import { ScrollView } from '@/components/ui/scroll-view'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; const gridImages: GalleryItem[] = [ { id: '1', uri: 'https://images.unsplash.com/photo-1637858868799-7f26a0640eb6?q=80&w=2960&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'City Skyline', description: 'Modern architecture at sunset', thumbnail: 'https://images.unsplash.com/photo-1637858868799-7f26a0640eb6?q=80&w=2960&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, { id: '2', uri: 'https://images.unsplash.com/photo-1644190022446-04b99df7259a?q=80&w=2012&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'Winter Wonderland', description: 'Snow-covered peaks and pristine wilderness', thumbnail: 'https://images.unsplash.com/photo-1644190022446-04b99df7259a?q=80&w=2012&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, { id: '3', uri: 'https://images.unsplash.com/photo-1717732596477-04f8c5d53387?q=80&w=987&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'Ocean Waves', description: 'Peaceful ocean scene with rolling waves', thumbnail: 'https://images.unsplash.com/photo-1717732596477-04f8c5d53387?q=80&w=987&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, { id: '4', uri: 'https://images.unsplash.com/photo-1575737698350-52e966f924d4?q=80&w=2070&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'Forest Path', description: 'A winding path through ancient trees', thumbnail: 'https://images.unsplash.com/photo-1575737698350-52e966f924d4?q=80&w=2070&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, { id: '5', uri: 'https://images.unsplash.com/photo-1667830867718-da7f5a45d20d?q=80&w=1064&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'Desert Dunes', description: 'Golden sand dunes stretching to the horizon', thumbnail: 'https://images.unsplash.com/photo-1667830867718-da7f5a45d20d?q=80&w=1064&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, { id: '6', uri: 'https://images.unsplash.com/photo-1593085512500-5d55148d6f0d?q=80&w=2334&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'Beautiful Landscape', description: 'A stunning view of mountains and valleys', thumbnail: 'https://images.unsplash.com/photo-1593085512500-5d55148d6f0d?q=80&w=2334&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, ]; export function GalleryGrid() { return ( 4 Columns, No Spacing 3 Columns with Spacing 2 Columns, Large Spacing ); } ``` #### With Titles and Descriptions **Example:** Gallery displaying image titles and descriptions ```tsx // components/demo/gallery/gallery-info.tsx import { Gallery, GalleryItem } from '@/components/ui/gallery'; import React from 'react'; const infoImages: GalleryItem[] = [ { id: '1', uri: 'https://images.unsplash.com/photo-1637858868799-7f26a0640eb6?q=80&w=2960&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'City Skyline', description: 'Modern architecture at sunset', thumbnail: 'https://images.unsplash.com/photo-1637858868799-7f26a0640eb6?q=80&w=2960&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, { id: '2', uri: 'https://images.unsplash.com/photo-1644190022446-04b99df7259a?q=80&w=2012&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'Winter Wonderland', description: 'Snow-covered peaks and pristine wilderness', thumbnail: 'https://images.unsplash.com/photo-1644190022446-04b99df7259a?q=80&w=2012&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, { id: '3', uri: 'https://images.unsplash.com/photo-1717732596477-04f8c5d53387?q=80&w=987&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'Ocean Waves', description: 'Peaceful ocean scene with rolling waves', thumbnail: 'https://images.unsplash.com/photo-1717732596477-04f8c5d53387?q=80&w=987&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, { id: '4', uri: 'https://images.unsplash.com/photo-1575737698350-52e966f924d4?q=80&w=2070&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'Forest Path', description: 'A winding path through ancient trees', thumbnail: 'https://images.unsplash.com/photo-1575737698350-52e966f924d4?q=80&w=2070&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, { id: '5', uri: 'https://images.unsplash.com/photo-1667830867718-da7f5a45d20d?q=80&w=1064&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'Desert Dunes', description: 'Golden sand dunes stretching to the horizon', thumbnail: 'https://images.unsplash.com/photo-1667830867718-da7f5a45d20d?q=80&w=1064&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, { id: '6', uri: 'https://images.unsplash.com/photo-1593085512500-5d55148d6f0d?q=80&w=2334&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'Beautiful Landscape', description: 'A stunning view of mountains and valleys', thumbnail: 'https://images.unsplash.com/photo-1593085512500-5d55148d6f0d?q=80&w=2334&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, ]; export function GalleryInfo() { return ( ); } ``` #### Different Layouts **Example:** Various gallery layouts and configurations ```tsx // components/demo/gallery/gallery-layouts.tsx import { Gallery, GalleryItem } from '@/components/ui/gallery'; import { ScrollView } from '@/components/ui/scroll-view'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; const layoutImages: GalleryItem[] = [ { id: '1', uri: 'https://images.unsplash.com/photo-1637858868799-7f26a0640eb6?q=80&w=2960&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'City Skyline', description: 'Modern architecture at sunset', thumbnail: 'https://images.unsplash.com/photo-1637858868799-7f26a0640eb6?q=80&w=2960&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, { id: '2', uri: 'https://images.unsplash.com/photo-1644190022446-04b99df7259a?q=80&w=2012&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'Winter Wonderland', description: 'Snow-covered peaks and pristine wilderness', thumbnail: 'https://images.unsplash.com/photo-1644190022446-04b99df7259a?q=80&w=2012&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, { id: '3', uri: 'https://images.unsplash.com/photo-1717732596477-04f8c5d53387?q=80&w=987&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'Ocean Waves', description: 'Peaceful ocean scene with rolling waves', thumbnail: 'https://images.unsplash.com/photo-1717732596477-04f8c5d53387?q=80&w=987&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, { id: '4', uri: 'https://images.unsplash.com/photo-1575737698350-52e966f924d4?q=80&w=2070&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'Forest Path', description: 'A winding path through ancient trees', thumbnail: 'https://images.unsplash.com/photo-1575737698350-52e966f924d4?q=80&w=2070&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, { id: '5', uri: 'https://images.unsplash.com/photo-1667830867718-da7f5a45d20d?q=80&w=1064&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'Desert Dunes', description: 'Golden sand dunes stretching to the horizon', thumbnail: 'https://images.unsplash.com/photo-1667830867718-da7f5a45d20d?q=80&w=1064&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, { id: '6', uri: 'https://images.unsplash.com/photo-1593085512500-5d55148d6f0d?q=80&w=2334&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'Beautiful Landscape', description: 'A stunning view of mountains and valleys', thumbnail: 'https://images.unsplash.com/photo-1593085512500-5d55148d6f0d?q=80&w=2334&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, ]; export function GalleryLayouts() { return ( Single Column (Feed Style) Square Grid Wide Thumbnails ); } ``` #### With Controls **Example:** Images with controls ```tsx // components/demo/gallery/gallery-controls.tsx import { Gallery, GalleryItem } from '@/components/ui/gallery'; import React from 'react'; import { Alert } from 'react-native'; const controlImages: GalleryItem[] = [ { id: '1', uri: 'https://images.unsplash.com/photo-1637858868799-7f26a0640eb6?q=80&w=2960&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'City Skyline', description: 'Modern architecture at sunset', thumbnail: 'https://images.unsplash.com/photo-1637858868799-7f26a0640eb6?q=80&w=2960&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, { id: '2', uri: 'https://images.unsplash.com/photo-1644190022446-04b99df7259a?q=80&w=2012&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'Winter Wonderland', description: 'Snow-covered peaks and pristine wilderness', thumbnail: 'https://images.unsplash.com/photo-1644190022446-04b99df7259a?q=80&w=2012&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, { id: '3', uri: 'https://images.unsplash.com/photo-1717732596477-04f8c5d53387?q=80&w=987&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'Ocean Waves', description: 'Peaceful ocean scene with rolling waves', thumbnail: 'https://images.unsplash.com/photo-1717732596477-04f8c5d53387?q=80&w=987&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, { id: '4', uri: 'https://images.unsplash.com/photo-1575737698350-52e966f924d4?q=80&w=2070&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'Forest Path', description: 'A winding path through ancient trees', thumbnail: 'https://images.unsplash.com/photo-1575737698350-52e966f924d4?q=80&w=2070&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, { id: '5', uri: 'https://images.unsplash.com/photo-1667830867718-da7f5a45d20d?q=80&w=1064&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'Desert Dunes', description: 'Golden sand dunes stretching to the horizon', thumbnail: 'https://images.unsplash.com/photo-1667830867718-da7f5a45d20d?q=80&w=1064&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, { id: '6', uri: 'https://images.unsplash.com/photo-1593085512500-5d55148d6f0d?q=80&w=2334&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'Beautiful Landscape', description: 'A stunning view of mountains and valleys', thumbnail: 'https://images.unsplash.com/photo-1593085512500-5d55148d6f0d?q=80&w=2334&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, ]; export function GalleryControls() { const handleDownload = (item: GalleryItem) => { Alert.alert('Download', `Downloading: ${item.title || 'Image'}`, [ { text: 'OK' }, ]); }; const handleShare = (item: GalleryItem) => { Alert.alert('Share', `Sharing: ${item.title || 'Image'}`, [{ text: 'OK' }]); }; return ( ); } ``` #### With Overlays **Example:** Images with overlay ```tsx // components/demo/gallery/gallery-overlay.tsx import { Gallery, GalleryItem } from '@/components/ui/gallery'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; const sampleImages: GalleryItem[] = [ { id: '1', uri: 'https://images.unsplash.com/photo-1637858868799-7f26a0640eb6?q=80&w=2960&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'City Skyline', description: 'Modern architecture at sunset', thumbnail: 'https://images.unsplash.com/photo-1637858868799-7f26a0640eb6?q=80&w=2960&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, { id: '2', uri: 'https://images.unsplash.com/photo-1644190022446-04b99df7259a?q=80&w=2012&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'Winter Wonderland', description: 'Snow-covered peaks and pristine wilderness', thumbnail: 'https://images.unsplash.com/photo-1644190022446-04b99df7259a?q=80&w=2012&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, { id: '3', uri: 'https://images.unsplash.com/photo-1717732596477-04f8c5d53387?q=80&w=987&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'Ocean Waves', description: 'Peaceful ocean scene with rolling waves', thumbnail: 'https://images.unsplash.com/photo-1717732596477-04f8c5d53387?q=80&w=987&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, { id: '4', uri: 'https://images.unsplash.com/photo-1575737698350-52e966f924d4?q=80&w=2070&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'Forest Path', description: 'A winding path through ancient trees', thumbnail: 'https://images.unsplash.com/photo-1575737698350-52e966f924d4?q=80&w=2070&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, { id: '5', uri: 'https://images.unsplash.com/photo-1667830867718-da7f5a45d20d?q=80&w=1064&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'Desert Dunes', description: 'Golden sand dunes stretching to the horizon', thumbnail: 'https://images.unsplash.com/photo-1667830867718-da7f5a45d20d?q=80&w=1064&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, { id: '6', uri: 'https://images.unsplash.com/photo-1593085512500-5d55148d6f0d?q=80&w=2334&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', title: 'Beautiful Landscape', description: 'A stunning view of mountains and valleys', thumbnail: 'https://images.unsplash.com/photo-1593085512500-5d55148d6f0d?q=80&w=2334&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }, ]; export function GalleryOverlay() { return ( ( {item.title} )} /> ); } ``` ## API Reference ### Gallery The main gallery component that displays images in a grid layout with fullscreen viewing capabilities. | Prop | Type | Default | Description | | --------------------- | ------------------------------------------------------- | ------- | ---------------------------------------------- | | `items` | `GalleryItem[]` | - | Array of gallery items to display. | | `columns` | `number` | `4` | Number of columns in the grid layout. | | `spacing` | `number` | `0` | Spacing between grid items in pixels. | | `borderRadius` | `number` | `0` | Border radius for gallery items. | | `aspectRatio` | `number` | `1` | Aspect ratio for gallery items (width/height). | | `showPages` | `boolean` | `false` | Show page indicator in fullscreen mode. | | `showTitles` | `boolean` | `false` | Show image titles in grid view. | | `showDescriptions` | `boolean` | `false` | Show image descriptions in grid view. | | `enableFullscreen` | `boolean` | `true` | Enable fullscreen viewing mode. | | `enableZoom` | `boolean` | `true` | Enable zoom functionality in fullscreen mode. | | `enableDownload` | `boolean` | `false` | Show download button in fullscreen mode. | | `enableShare` | `boolean` | `false` | Show share button in fullscreen mode. | | `onItemPress` | `(item: GalleryItem, index: number) => void` | - | Custom handler for item press events. | | `onDownload` | `(item: GalleryItem) => void` | - | Handler for download button press. | | `onShare` | `(item: GalleryItem) => void` | - | Handler for share button press. | | `renderCustomOverlay` | `(item: GalleryItem, index: number) => React.ReactNode` | - | Custom overlay renderer for grid items. | ### GalleryItem Interface for gallery items. | Prop | Type | Required | Description | | ------------- | -------- | -------- | --------------------------------------------- | | `id` | `string` | Yes | Unique identifier for the gallery item. | | `uri` | `string` | Yes | Image URI for the full-size image. | | `title` | `string` | No | Optional title for the image. | | `description` | `string` | No | Optional description for the image. | | `thumbnail` | `string` | No | Optional thumbnail URI (falls back to `uri`). | ## Features ### Gesture Support - **Pinch to zoom**: Zoom in and out on images in fullscreen mode - **Double tap to zoom**: Quick zoom to 2.5x scale or reset to original size - **Pan when zoomed**: Drag zoomed images to view different areas - **Swipe navigation**: Horizontal swipe to navigate between images ### Fullscreen Mode - Immersive fullscreen viewing experience - Thumbnail navigation bar at the bottom - Top controls for sharing, downloading, and closing - Page indicators showing current position ### Responsive Design - Automatically adjusts to screen size - Configurable grid columns and spacing - Maintains aspect ratios across different devices - Smooth animations and transitions ### Performance Optimizations - Lazy loading of off-screen images - Efficient gesture handling with React Native Reanimated - Optimized FlatList rendering for large galleries - Thumbnail support for faster grid loading ## Accessibility The Gallery component includes accessibility features: - Proper keyboard navigation support - Screen reader compatibility - High contrast support for UI elements - Semantic structure for assistive technologies - Touch target sizes meet accessibility guidelines ## Troubleshooting ### Common Issues **Gestures not working:** - Ensure `react-native-gesture-handler` is properly installed and configured - Make sure the component is wrapped in `GestureHandlerRootView` **Images not loading:** - Verify image URIs are accessible - Check network connectivity - Ensure proper CORS configuration for web images **Performance issues with large galleries:** - Consider implementing pagination - Use thumbnail images for grid view - Optimize image sizes and formats **Zoom not working properly:** - Verify `react-native-reanimated` is correctly set up - Check that `enableZoom` prop is set to `true` - Ensure proper gesture handler configuration # Hello Wave > An animated waving hand emoji component with smooth rotation animation and customizable sizes. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/hello-wave - Markdown: https://ui.ahmedbna.com/docs/components/hello-wave.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/hello-wave.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/hello-wave.json - Install: `npx bna-ui add hello-wave` - npm dependencies: `react-native-reanimated`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `text`, `view` - Preview recording: https://demo.ahmedbna.com/0151-hello-wave-demo.mov --- **Example:** An animated waving hand emoji with size variants ```tsx // components/demo/hello-wave/hello-wave-demo.tsx import { HelloWave } from '@/components/ui/hello-wave'; import React from 'react'; export function HellowWaveDemo() { return 👋; } ``` ## Installation ### CLI ```bash npx bna-ui add hello-wave ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install react-native-reanimated ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/hello-wave.tsx import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useEffect } from 'react'; import Animated, { useAnimatedStyle, useSharedValue, withRepeat, withSequence, withTiming, } from 'react-native-reanimated'; interface HelloWaveProps { size?: 'sm' | 'md' | 'lg'; children?: React.ReactNode; } const sizeVariants = { sm: { fontSize: 20, lineHeight: 24, marginTop: -4, }, md: { fontSize: 28, lineHeight: 32, marginTop: -6, }, lg: { fontSize: 36, lineHeight: 40, marginTop: -8, }, }; export function HelloWave({ children = '👋', size = 'md' }: HelloWaveProps) { const rotationAnimation = useSharedValue(0); useEffect(() => { rotationAnimation.value = withRepeat( withSequence( withTiming(25, { duration: 150 }), withTiming(0, { duration: 150 }) ), 4 // Run the animation 4 times ); }, [rotationAnimation]); const animatedStyle = useAnimatedStyle(() => ({ transform: [ { rotate: `${rotationAnimation.value}deg`, }, ], })); const sizeStyle = sizeVariants[size]; return ( {typeof children === 'string' ? ( {children} ) : ( children )} ); } ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { HelloWave } from '@/components/ui/hello-wave'; ``` ```tsx ``` ```tsx ``` ```tsx 🙋‍♀️ ``` ## Examples #### Default **Example:** Default animated waving hand emoji ```tsx // components/demo/hello-wave/hello-wave-demo.tsx import { HelloWave } from '@/components/ui/hello-wave'; import React from 'react'; export function HellowWaveDemo() { return 👋; } ``` ## API Reference ### HelloWave An animated component that displays a waving hand emoji with rotation animation and customizable sizes. | Prop | Type | Default | Description | | ---------- | ---------------------- | ------- | --------------------------------------------------- | | `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Controls the size of the emoji and animation scale. | | `children` | `React.ReactNode` | `'👋'` | Content to animate - typically an emoji string. | ## Size Variants The component includes three predefined size variants: | Size | Font Size | Line Height | Use Case | | ---- | --------- | ----------- | --------------------------- | | `sm` | 20px | 24px | Inline text, compact spaces | | `md` | 28px | 32px | Default size, most contexts | | `lg` | 36px | 40px | Headers, prominent display | ## Animation Details - **Duration**: 150ms for each rotation phase - **Rotation Range**: 0° to 25° and back to 0° - **Repetitions**: 4 complete wave cycles - **Timing**: Runs automatically on component mount - **Easing**: Uses default timing function for smooth animation ## Customization ### Custom Content You can pass any content as children, not just the default wave emoji: ```tsx 🙋‍♀️ 🎉 ``` ### Custom Components For more complex customization, you can pass React components: ```tsx ``` ### Styling The component uses a centered container layout. For additional styling, wrap the component: ```tsx ``` ## Technical Notes - Uses `react-native-reanimated` for smooth 60fps animations - Animation runs on the UI thread for better performance - Automatically starts animation when component mounts - Uses `useSharedValue` and `useAnimatedStyle` for optimal performance - Size variants include margin adjustments for proper vertical alignment - Supports both string content and React components as children ## Accessibility The HelloWave component: - Uses semantic emoji that screen readers can interpret - Maintains proper text sizing for accessibility - Works with system accessibility settings - Animation doesn't interfere with screen reader functionality - Proper font sizing scales with system text size preferences # Icon > A themed icon component with support for Lucide React Native icons. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/icon - Markdown: https://ui.ahmedbna.com/docs/components/icon.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/icon.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/icon.json - Install: `npx bna-ui add icon` - npm dependencies: `lucide-react-native`, `react-native-svg` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `text`, `view` - Preview recording: https://demo.ahmedbna.com/0152-icon-demo.PNG --- **Example:** A basic icon with default styling ```tsx // components/demo/icon/icon-demo.tsx import { Icon } from '@/components/ui/icon'; import { Heart } from 'lucide-react-native'; import React from 'react'; export function IconDemo() { return ; } ``` ## Installation ### CLI ```bash npx bna-ui add icon ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install lucide-react-native ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/icon.tsx import { useColor } from '@/hooks/useColor'; import { LucideProps } from 'lucide-react-native'; import React from 'react'; export type Props = LucideProps & { lightColor?: string; darkColor?: string; name: React.ComponentType; }; export function Icon({ lightColor, darkColor, name: IconComponent, color, size = 24, strokeWidth = 1.8, accessible = false, ...rest }: Props) { const themedColor = useColor('icon', { light: lightColor, dark: darkColor }); // Use provided color prop if available, otherwise use themed color const iconColor = color || themedColor; return ( ); } ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { Icon } from '@/components/ui/icon'; import { Heart, Star, Home } from 'lucide-react-native'; ``` ```tsx ``` ## Examples #### Default **Example:** A basic icon with default styling ```tsx // components/demo/icon/icon-demo.tsx import { Icon } from '@/components/ui/icon'; import { Heart } from 'lucide-react-native'; import React from 'react'; export function IconDemo() { return ; } ``` #### Sizes **Example:** Icons in different sizes ```tsx // components/demo/icon/icon-sizes.tsx import { Icon } from '@/components/ui/icon'; import { View } from '@/components/ui/view'; import { Star } from 'lucide-react-native'; import React from 'react'; export function IconSizes() { const sizes = [16, 20, 24, 32, 40, 48]; return ( {sizes.map((size) => ( ))} ); } ``` #### Colors **Example:** Icons with custom colors and themed colors ```tsx // components/demo/icon/icon-colors.tsx import { Icon } from '@/components/ui/icon'; import { View } from '@/components/ui/view'; import { Circle } from 'lucide-react-native'; import React from 'react'; export function IconColors() { const colors = [ '#FF6B6B', // Red '#4ECDC4', // Teal '#45B7D1', // Blue '#96CEB4', // Green '#FECA57', // Yellow '#FF9FF3', // Pink '#54A0FF', // Light Blue '#5F27CD', // Purple ]; return ( {colors.map((color, index) => ( ))} ); } ``` #### Stroke Weights **Example:** Icons with different stroke weights ```tsx // components/demo/icon/icon-stroke.tsx import { Icon } from '@/components/ui/icon'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { Zap } from 'lucide-react-native'; import React from 'react'; export function IconStroke() { const strokeWeights = [ { weight: 1, label: 'Light' }, { weight: 1.5, label: 'Regular' }, { weight: 2, label: 'Medium' }, { weight: 2.5, label: 'Bold' }, ]; return ( {strokeWeights.map(({ weight, label }) => ( {label} ))} ); } ``` #### Interactive Icons **Example:** Icons with press and hover interactions ```tsx // components/demo/icon/icon-interactive.tsx import { Button } from '@/components/ui/button'; import { Icon } from '@/components/ui/icon'; import { View } from '@/components/ui/view'; import { Bookmark, Heart, Share, ThumbsUp } from 'lucide-react-native'; import React, { useState } from 'react'; export function IconInteractive() { const [liked, setLiked] = useState(false); const [thumbsUp, setThumbsUp] = useState(false); const [bookmarked, setBookmarked] = useState(false); const iconButtons = [ { icon: Heart, active: liked, onPress: () => setLiked(!liked), activeColor: '#FF6B6B', inactiveColor: '#888', }, { icon: ThumbsUp, active: thumbsUp, onPress: () => setThumbsUp(!thumbsUp), activeColor: '#4ECDC4', inactiveColor: '#888', }, { icon: Bookmark, active: bookmarked, onPress: () => setBookmarked(!bookmarked), activeColor: '#FECA57', inactiveColor: '#888', }, { icon: Share, active: false, onPress: () => {}, activeColor: '#45B7D1', inactiveColor: '#888', }, ]; return ( {iconButtons.map( ({ icon, active, onPress, activeColor, inactiveColor }, index) => ( ) )} ); } ``` #### Icon Grid **Example:** A grid of commonly used icons ```tsx // components/demo/icon/icon-grid.tsx import { Icon } from '@/components/ui/icon'; import { View } from '@/components/ui/view'; import { Bell, Calendar, Camera, Download, Edit, Heart, Home, Mail, Minus, Plus, Search, Settings, Star, Trash, Upload, User, } from 'lucide-react-native'; import React from 'react'; export function IconGrid() { const icons = [ Home, Search, Bell, User, Settings, Heart, Star, Mail, Calendar, Camera, Download, Upload, Edit, Trash, Plus, Minus, ]; return ( {icons.map((IconComponent, index) => ( ))} ); } ``` #### Themed Icons **Example:** Icons that adapt to light and dark themes ```tsx // components/demo/icon/icon-themed.tsx import { Icon } from '@/components/ui/icon'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { Monitor, Moon, Palette, Sun } from 'lucide-react-native'; import React from 'react'; export function IconThemed() { const themedIcons = [ { icon: Sun, label: 'Light Theme', lightColor: '#FFA500', darkColor: '#FFD700', }, { icon: Moon, label: 'Dark Theme', lightColor: '#4A5568', darkColor: '#E2E8F0', }, { icon: Monitor, label: 'System', lightColor: '#2D3748', darkColor: '#F7FAFC', }, { icon: Palette, label: 'Custom', lightColor: '#E53E3E', darkColor: '#FC8181', }, ]; return ( {themedIcons.map(({ icon, label, lightColor, darkColor }, index) => ( {label} ))} ); } ``` ## API Reference ### Icon The main icon component that renders Lucide React Native icons with theming support. | Prop | Type | Default | Description | | ------------- | ---------------------------------- | ------- | ----------------------------------------------------- | | `name` | `React.ComponentType` | - | The Lucide icon component to render. | | `size` | `number` | `24` | The size of the icon in pixels. | | `color` | `string` | - | Custom color for the icon. | | `lightColor` | `string` | - | Color to use in light theme. | | `darkColor` | `string` | - | Color to use in dark theme. | | `strokeWidth` | `number` | `1.8` | The stroke width of the icon. | | `...rest` | `LucideProps` | - | Additional props passed to the Lucide icon component. | ### LucideProps The Icon component accepts all props from Lucide React Native icons: | Prop | Type | Default | Description | | ---------------- | -------- | ------- | -------------------------------- | | `strokeLinecap` | `string` | `round` | The line cap style for strokes. | | `strokeLinejoin` | `string` | - | The line join style for strokes. | | `fill` | `string` | - | Fill color for the icon. | | `fillOpacity` | `number` | - | Opacity of the fill color. | | `strokeOpacity` | `number` | - | Opacity of the stroke color. | ## Accessibility The Icon component is built with accessibility in mind: - Icons are decorative by default and hidden from screen readers - When icons convey important information, wrap them with accessible text - Proper color contrast for themed icons - Supports dynamic text sizing through size prop ## Common Icon Patterns ### With Labels ```tsx import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; Favorites ; ``` ### As Buttons ```tsx import { Pressable } from 'react-native'; ; ``` ### Navigation Icons ```tsx ``` # Image > A responsive image component with loading states, error handling, and flexible styling options. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/image - Markdown: https://ui.ahmedbna.com/docs/components/image.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/image.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/image.json - Install: `npx bna-ui add image` - npm dependencies: `expo-image` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `text`, `view` - Preview recording: https://demo.ahmedbna.com/0159-image-demo.PNG --- **Example:** A basic image with loading indicator and error fallback ```tsx // components/demo/image/image-demo.tsx import { Image } from '@/components/ui/image'; export function ImageDemo() { return ( ); } ``` ## Installation ### CLI ```bash npx bna-ui add image ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install expo-image ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/image.tsx import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { BORDER_RADIUS, CORNERS } from '@/theme/globals'; import { Image as ExpoImage, ImageProps as ExpoImageProps, ImageSource, } from 'expo-image'; import { forwardRef, useState } from 'react'; import { ActivityIndicator, StyleSheet } from 'react-native'; export interface ImageProps extends Omit { variant?: 'rounded' | 'circle' | 'default'; source: ImageSource; style?: ExpoImageProps['style']; containerStyle?: any; showLoadingIndicator?: boolean; showErrorFallback?: boolean; errorFallbackText?: string; loadingIndicatorSize?: 'small' | 'large'; loadingIndicatorColor?: string; aspectRatio?: number; width?: number | string; height?: number | string; } export const Image = forwardRef( ( { variant = 'rounded', source, style, containerStyle, showLoadingIndicator = true, showErrorFallback = true, errorFallbackText = 'Failed to load image', loadingIndicatorSize = 'small', loadingIndicatorColor, aspectRatio, width, height, contentFit = 'cover', transition = 200, onLoadStart, onLoadEnd, onError, ...props }, ref ) => { const [isLoading, setIsLoading] = useState(true); const [hasError, setHasError] = useState(false); // Theme colors const backgroundColor = useColor('muted'); const textColor = useColor('mutedForeground'); const primaryColor = useColor('primary'); // Get border radius based on variant const getBorderRadius = () => { switch (variant) { case 'circle': return CORNERS; case 'rounded': return BORDER_RADIUS; case 'default': return 0; default: return BORDER_RADIUS; } }; const borderRadius = getBorderRadius(); // Container dimensions - fill container by default, or use provided dimensions const containerDimensions = width || height || aspectRatio ? { ...(width ? { width } : {}), ...(height ? { height } : {}), ...(aspectRatio ? { aspectRatio } : {}), } : { width: '100%', height: '100%' }; // Image styles - always fill the container const imageStyles = [ { width: '100%', height: '100%', borderRadius }, style, ].filter(Boolean) as ExpoImageProps['style']; const containerStyles = [ styles.container, containerDimensions, { borderRadius, backgroundColor }, containerStyle, ]; // Compose explicitly rather than relying on {...props} spread order — // a consumer's own onLoadStart/onLoadEnd/onError must not silently // replace the internal handler that drives isLoading/hasError. const handleLoadStart: NonNullable = ( ...args ) => { setIsLoading(true); setHasError(false); onLoadStart?.(...args); }; const handleLoadEnd: NonNullable = (...args) => { setIsLoading(false); onLoadEnd?.(...args); }; const handleError: NonNullable = (...args) => { setIsLoading(false); setHasError(true); onError?.(...args); }; return ( {/* Loading indicator */} {isLoading && showLoadingIndicator && ( )} {/* Error fallback */} {hasError && showErrorFallback && ( {errorFallbackText} )} ); } ); const styles = StyleSheet.create({ container: { position: 'relative', overflow: 'hidden', }, overlay: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, justifyContent: 'center', alignItems: 'center', }, errorContainer: { padding: 8, }, errorText: { textAlign: 'center', fontSize: 12, }, }); Image.displayName = 'Image'; ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { Image } from '@/components/ui/image'; ``` ```tsx ``` ## Examples #### Default **Example:** A basic image with loading indicator and error fallback ```tsx // components/demo/image/image-demo.tsx import { Image } from '@/components/ui/image'; export function ImageDemo() { return ( ); } ``` #### Variants **Example:** Images with different border radius variants ```tsx // components/demo/image/image-variants.tsx import { Image } from '@/components/ui/image'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function ImageVariants() { return ( Rounded Circle Default ); } ``` #### Sizes **Example:** Images in different sizes and aspect ratios ```tsx // components/demo/image/image-sizes.tsx import { Image } from '@/components/ui/image'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function ImageSizes() { return ( Aspect Ratio Examples ); } ``` #### Loading States **Example:** Images with different loading indicator configurations ```tsx // components/demo/image/image-loading.tsx import { Image } from '@/components/ui/image'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function ImageLoading() { return ( Small Loading Indicator Large Loading Indicator Custom Loading Color No Loading Indicator ); } ``` #### Error Handling **Example:** Images with custom error fallback messages ```tsx // components/demo/image/image-error.tsx import { Image } from '@/components/ui/image'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function ImageError() { return ( Default Error Fallback Custom Error Message No Error Fallback Circle Variant with Error ); } ``` #### Gallery **Example:** Multiple images arranged in a gallery layout ```tsx // components/demo/image/image-gallery.tsx import { Image } from '@/components/ui/image'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; import { ScrollView } from 'react-native'; export function ImageGallery() { const images = [ 'https://picsum.photos/300/200?random=10', 'https://picsum.photos/300/200?random=11', 'https://picsum.photos/300/200?random=12', 'https://picsum.photos/300/200?random=13', 'https://picsum.photos/300/200?random=14', 'https://picsum.photos/300/200?random=15', ]; return ( Grid Gallery {images.slice(0, 4).map((uri, index) => ( ))} Horizontal Scroll Gallery {images.map((uri, index) => ( ))} Featured Image ); } ``` #### Responsive **Example:** Responsive images that adapt to container size ```tsx // components/demo/image/image-responsive.tsx import { Image } from '@/components/ui/image'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function ImageResponsive() { return ( Full Width (Container Responsive) Percentage Width Flex Layout ); } ``` #### Content Fit **Example:** Images with different content fit modes ```tsx // components/demo/image/image-content-fit.tsx import { Image } from '@/components/ui/image'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function ImageContentFit() { const imageUri = 'https://picsum.photos/600/400?random=30'; return ( Cover (Default) Contain Fill Scale Down None ); } ``` ## API Reference ### Image A responsive image component with loading states and error handling. | Prop | Type | Default | Description | | ----------------------- | ------------------------------------ | ------------------------ | ---------------------------------------------------------------- | | `source` | `ImageSource` | - | The image source (required). | | `variant` | `'rounded' \| 'circle' \| 'default'` | `'rounded'` | The border radius variant. `'default'` applies no border radius. | | `width` | `number \| string` | - | The width of the image. | | `height` | `number \| string` | - | The height of the image. | | `aspectRatio` | `number` | - | The aspect ratio of the image. | | `contentFit` | `ContentFit` | `'cover'` | How the image should fit within its bounds. | | `showLoadingIndicator` | `boolean` | `true` | Whether to show loading indicator. | | `showErrorFallback` | `boolean` | `true` | Whether to show error fallback. | | `errorFallbackText` | `string` | `'Failed to load image'` | The error fallback text. | | `loadingIndicatorSize` | `'small' \| 'large'` | `'small'` | The size of the loading indicator. | | `loadingIndicatorColor` | `string` | - | The color of the loading indicator. | | `transition` | `number` | `200` | The transition duration in milliseconds. | | `style` | `ImageProps['style']` | - | Additional styles to apply to the image. | | `containerStyle` | `ViewStyle` | - | Additional styles to apply to the container. | | `onLoadStart` | `() => void` | - | Callback when image starts loading. | | `onLoadEnd` | `() => void` | - | Callback when image finishes loading. | | `onError` | `() => void` | - | Callback when image fails to load. | ### Content Fit Options The `contentFit` prop accepts the following values: - `'cover'` - Scale the image to cover the entire container - `'contain'` - Scale the image to fit within the container - `'fill'` - Stretch the image to fill the container - `'none'` - Display the image at its natural size - `'scale-down'` - Scale down the image if it's larger than the container ## Accessibility The Image component is built with accessibility in mind: - Supports `accessibilityLabel` for screen readers - Error fallback provides alternative content when images fail to load - Loading indicators communicate loading state to assistive technologies - Proper semantic structure for better navigation ## Performance The Image component uses Expo Image under the hood, which provides: - Automatic image caching - Optimized memory usage - Support for various image formats - Smooth transitions and loading states - Network-aware loading strategies # Input OTP > A secure input component for one-time passwords and verification codes. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/input-otp - Markdown: https://ui.ahmedbna.com/docs/components/input-otp.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/input-otp.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/input-otp.json - Install: `npx bna-ui add input-otp` - npm dependencies: `expo-haptics` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `useHaptics`, `globals`, `text` - Preview recording: https://demo.ahmedbna.com/0167-input-otp-demo.MP4 --- **Example:** A basic OTP input with 6 digits ```tsx // components/demo/input-otp/input-otp-demo.tsx import { InputOTP } from '@/components/ui/input-otp'; import React, { useState } from 'react'; export function InputOTPDemo() { const [otp, setOtp] = useState(''); return ( { console.log('OTP Complete:', value); }} /> ); } ``` ## Installation ### CLI ```bash npx bna-ui add input-otp ``` ### Manual **1.** Copy and paste the following code into your project. ```tsx // components/ui/input-otp.tsx import { Text } from '@/components/ui/text'; import { useColor } from '@/hooks/useColor'; import { useHaptics } from '@/hooks/useHaptics'; import { CORNERS, FONT_SIZE } from '@/theme/globals'; import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, } from 'react'; import { Keyboard, NativeSyntheticEvent, Platform, Pressable, TextInput, TextInputKeyPressEventData, TextInputProps, TextStyle, View, ViewStyle, } from 'react-native'; export interface InputOTPProps extends Omit< TextInputProps, 'style' | 'value' | 'onChangeText' > { /** Number of OTP digits */ length?: number; /** Current OTP value */ value?: string; /** Called when OTP value changes */ onChangeText?: (value: string) => void; /** Called when OTP is complete */ onComplete?: (value: string) => void; /** Error message to display */ error?: string; /** Disabled state */ disabled?: boolean; /** Container style */ containerStyle?: ViewStyle; /** Individual slot style */ slotStyle?: ViewStyle; /** Error style */ errorStyle?: TextStyle; /** Whether to mask the input (show dots instead of numbers) */ masked?: boolean; /** Separator component between slots */ separator?: React.ReactNode; /** Whether to show cursor in active slot */ showCursor?: boolean; /** Whether to trigger haptic feedback when the code is complete */ haptic?: boolean; } export interface InputOTPRef { focus: () => void; blur: () => void; clear: () => void; getValue: () => string; } export const InputOTP = forwardRef( ( { length = 6, value = '', onChangeText, onComplete, error, disabled = false, containerStyle, slotStyle, errorStyle, masked = false, separator, showCursor = true, haptic = true, onFocus, onBlur, ...textInputProps }, ref ) => { const [isFocused, setIsFocused] = useState(false); const [activeIndex, setActiveIndex] = useState(0); const inputRef = useRef(null); const refocusTimeout = useRef | null>(null); // Android dismisses the keyboard (back button, swipe down) without blurring // the input, so tracking it is the only way to tell "still typing" from // "focused, keyboard gone" when a slot is pressed. See focusInput below. const keyboardVisible = useRef(false); const feedback = useHaptics(haptic); useEffect(() => { const subscriptions = Platform.OS === 'android' ? [ Keyboard.addListener('keyboardDidShow', () => { keyboardVisible.current = true; }), Keyboard.addListener('keyboardDidHide', () => { keyboardVisible.current = false; }), ] : []; return () => { subscriptions.forEach((subscription) => subscription.remove()); if (refocusTimeout.current) clearTimeout(refocusTimeout.current); }; }, []); const focusInput = useCallback(() => { // On Android the input stays focused after the keyboard is dismissed and // no onBlur fires, which makes focus() a no-op — the keyboard never comes // back. Cycling blur -> focus reopens it. Guarded on the keyboard being // down so pressing a slot mid-entry doesn't flash the keyboard closed. if ( Platform.OS === 'android' && !keyboardVisible.current && inputRef.current?.isFocused() ) { inputRef.current.blur(); if (refocusTimeout.current) clearTimeout(refocusTimeout.current); refocusTimeout.current = setTimeout(() => { inputRef.current?.focus(); }, 50); return; } inputRef.current?.focus(); }, []); // Theme colors const cardColor = useColor('card'); const textColor = useColor('text'); const muted = useColor('textMuted'); const borderColor = useColor('border'); const primary = useColor('primary'); const danger = useColor('red'); const background = useColor('background'); // Normalize value to ensure it doesn't exceed length const normalizedValue = value.slice(0, length); // Calculate active index based on current value const currentActiveIndex = Math.min(normalizedValue.length, length - 1); // Expose methods via ref useImperativeHandle(ref, () => ({ focus: focusInput, blur: () => { if (refocusTimeout.current) clearTimeout(refocusTimeout.current); inputRef.current?.blur(); }, clear: () => { onChangeText?.(''); setActiveIndex(0); }, getValue: () => normalizedValue, })); const handleChangeText = useCallback( (text: string) => { // Only allow numeric input const cleanText = text.replace(/[^0-9]/g, ''); const limitedText = cleanText.slice(0, length); onChangeText?.(limitedText); setActiveIndex(Math.min(limitedText.length, length - 1)); // Call onComplete when OTP is fully entered. // Deliberately the only haptic here: the system keyboard already emits // its own key click, so a per-keystroke buzz would double up on the one // interaction the user repeats `length` times. if (limitedText.length === length) { feedback('success'); onComplete?.(limitedText); } }, [length, onChangeText, onComplete, feedback] ); const handleKeyPress = useCallback( (e: NativeSyntheticEvent) => { const { key } = e.nativeEvent; if (key === 'Backspace' && normalizedValue.length > 0) { const newValue = normalizedValue.slice(0, -1); onChangeText?.(newValue); setActiveIndex(Math.max(0, newValue.length)); } }, [normalizedValue, onChangeText] ); const handleFocus = useCallback( (e: any) => { setIsFocused(true); setActiveIndex(normalizedValue.length); onFocus?.(e); }, [normalizedValue.length, onFocus] ); const handleBlur = useCallback( (e: any) => { setIsFocused(false); onBlur?.(e); }, [onBlur] ); const handleSlotPress = useCallback(() => { if (!disabled) { focusInput(); } }, [disabled, focusInput]); // Generate slots const slots = Array.from({ length }, (_, index) => { const hasValue = index < normalizedValue.length; const isActive = isFocused && index === currentActiveIndex; const displayValue = hasValue ? masked ? '•' : normalizedValue[index] : ''; return ( {displayValue} {/* Cursor */} {showCursor && isActive && !hasValue && ( )} {/* Separator */} {separator && index < length - 1 && ( {separator} )} ); }); const renderContent = () => ( {/* Hidden TextInput for handling input */} {/* OTP Slots */} {slots} {/* Error Message */} {error && ( {error} )} ); return renderContent(); } ); InputOTP.displayName = 'InputOTP'; // Optional: Export a preset with separator export const InputOTPWithSeparator = forwardRef< InputOTPRef, Omit >((props, ref) => ( - } {...props} /> )); InputOTPWithSeparator.displayName = 'InputOTPWithSeparator'; ``` **2.** Update the import paths to match your project setup. ## Usage ```tsx import { InputOTP, InputOTPWithSeparator } from '@/components/ui/input-otp'; ``` ```tsx console.log('OTP Complete:', value)} /> ``` ## Examples #### Default **Example:** A basic OTP input with 6 digits ```tsx // components/demo/input-otp/input-otp-demo.tsx import { InputOTP } from '@/components/ui/input-otp'; import React, { useState } from 'react'; export function InputOTPDemo() { const [otp, setOtp] = useState(''); return ( { console.log('OTP Complete:', value); }} /> ); } ``` #### Different Lengths **Example:** OTP inputs with different digit lengths ```tsx // components/demo/input-otp/input-otp-lengths.tsx import { InputOTP } from '@/components/ui/input-otp'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function InputOTPLengths() { const [otp4, setOtp4] = useState(''); const [otp6, setOtp6] = useState(''); return ( 4 Digits 6 Digits (Default) ); } ``` #### With Separator **Example:** OTP input with dash separators between digits ```tsx // components/demo/input-otp/input-otp-separator.tsx import { InputOTP, InputOTPWithSeparator } from '@/components/ui/input-otp'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import React, { useState } from 'react'; export function InputOTPSeparator() { const [otp1, setOtp1] = useState(''); const [otp2, setOtp2] = useState(''); const [otp3, setOtp3] = useState(''); const muted = useColor('textMuted'); return ( With Dash Separator With Dot Separator • } /> With Custom Separator } /> ); } ``` #### Masked Input **Example:** OTP input that masks digits with dots for security ```tsx // components/demo/input-otp/input-otp-masked.tsx import { InputOTP } from '@/components/ui/input-otp'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function InputOTPMasked() { const [normalOtp, setNormalOtp] = useState(''); const [maskedOtp, setMaskedOtp] = useState(''); return ( Normal (Visible Digits) {normalOtp && ( Current value: {normalOtp} )} Masked (Hidden Digits) {maskedOtp && ( Current value: {maskedOtp} )} ); } ``` #### Error State **Example:** OTP input showing error state with validation message ```tsx // components/demo/input-otp/input-otp-error.tsx import { Button } from '@/components/ui/button'; import { InputOTP } from '@/components/ui/input-otp'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function InputOTPError() { const [otp, setOtp] = useState(''); const [error, setError] = useState(''); const validateOtp = (value: string) => { if (value.length === 6) { // Simulate validation - reject if all digits are the same if (value === '111111' || value === '000000') { setError('Invalid verification code. Please try again.'); } else { setError(''); } } else { setError(''); } }; const handleOtpChange = (value: string) => { setOtp(value); validateOtp(value); }; const simulateError = () => { setError('Verification code has expired. Please request a new one.'); }; const clearError = () => { setError(''); setOtp(''); }; return ( Enter Verification Code Try entering "111111" or "000000" to see error state { if (!error) { console.log('Valid OTP:', value); } }} /> ); } ``` #### Disabled State **Example:** OTP input in disabled state ```tsx // components/demo/input-otp/input-otp-disabled.tsx import { Button } from '@/components/ui/button'; import { InputOTP } from '@/components/ui/input-otp'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function InputOTPDisabled() { const [otp, setOtp] = useState('123'); const [disabled, setDisabled] = useState(true); return ( Disabled State Toggle the button below to enable/disable the input {!disabled && ( Current value: {otp} )} ); } ``` #### Custom Styling **Example:** OTP input with custom colors and styling ```tsx // components/demo/input-otp/input-otp-styled.tsx import { InputOTP } from '@/components/ui/input-otp'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import React, { useState } from 'react'; export function InputOTPStyled() { const [otp1, setOtp1] = useState(''); const [otp2, setOtp2] = useState(''); const [otp3, setOtp3] = useState(''); const primary = useColor('primary'); const success = '#10B981'; const purple = '#8B5CF6'; return ( Rounded Style Success Theme Large & Purple ); } ``` #### Without Cursor **Example:** OTP input without the blinking cursor indicator ```tsx // components/demo/input-otp/input-otp-no-cursor.tsx import { InputOTP } from '@/components/ui/input-otp'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function InputOTPNoCursor() { const [otpWithCursor, setOtpWithCursor] = useState(''); const [otpWithoutCursor, setOtpWithoutCursor] = useState(''); return ( With Cursor (Default) Without Cursor Tap on the inputs above to see the difference in cursor behavior ); } ``` ## API Reference ### InputOTP The main OTP input component for handling one-time passwords and verification codes. | Prop | Type | Default | Description | | ---------------- | ------------------------- | ------- | ------------------------------------------------ | | `length` | `number` | `6` | Number of OTP digits to display. | | `value` | `string` | `''` | Current OTP value. | | `onChangeText` | `(value: string) => void` | - | Called when OTP value changes. | | `onComplete` | `(value: string) => void` | - | Called when OTP is complete (all digits filled). | | `error` | `string` | - | Error message to display below the input. | | `disabled` | `boolean` | `false` | Whether the input is disabled. | | `masked` | `boolean` | `false` | Whether to mask digits with dots for security. | | `showCursor` | `boolean` | `true` | Whether to show cursor in the active slot. | | `separator` | `ReactNode` | - | Custom separator component between slots. | | `containerStyle` | `ViewStyle` | - | Additional styles for the container. | | `slotStyle` | `ViewStyle` | - | Additional styles for individual digit slots. | | `errorStyle` | `TextStyle` | - | Additional styles for the error message. | ### InputOTPWithSeparator A preset variant of InputOTP that includes dash separators between digits. | Prop | Type | Default | Description | | ------------------------------------------ | ---- | ------- | ----------------------------------------------------------------------- | | All props from InputOTP except `separator` | - | - | Inherits all InputOTP props except separator which is preset to a dash. | ### InputOTPRef Reference object that provides programmatic control over the InputOTP component. | Method | Type | Description | | ---------- | -------------- | ------------------------------ | | `focus` | `() => void` | Focuses the input. | | `blur` | `() => void` | Blurs the input. | | `clear` | `() => void` | Clears all entered digits. | | `getValue` | `() => string` | Returns the current OTP value. | ## Usage with Ref ```tsx import { useRef } from 'react'; import { InputOTP, InputOTPRef } from '@/components/ui/input-otp'; export function MyComponent() { const otpRef = useRef(null); const handleClear = () => { otpRef.current?.clear(); }; const handleFocus = () => { otpRef.current?.focus(); }; return ( { console.log('OTP entered:', value); }} /> ); } ``` ## Accessibility The InputOTP component is built with accessibility in mind: - The hidden `TextInput` sets `textContentType="oneTimeCode"` and `autoComplete="one-time-code"`, enabling native SMS autofill - Each digit slot exposes an `accessibilityLabel` announcing its position and filled/empty state - Error messages are rendered as visible text below the input ## Security Considerations - Use the `masked` prop when dealing with sensitive verification codes - Always validate OTP values on the server side - Consider implementing rate limiting for OTP attempts - Clear sensitive OTP values from memory when no longer needed # Input > A styled text input component with label, validation, icons, and grouped layouts. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/input - Markdown: https://ui.ahmedbna.com/docs/components/input.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/input.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/input.json - Install: `npx bna-ui add input` - npm dependencies: `lucide-react-native`, `react-native-svg` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `text`, `view`, `icon` - Preview recording: https://demo.ahmedbna.com/0175-input-demo.MP4 --- **Example:** A basic input with label and placeholder ```tsx // components/demo/input/input-demo.tsx import { Input } from '@/components/ui/input'; import { User } from 'lucide-react-native'; import React from 'react'; export function InputDemo() { return ( ); } ``` ## Installation ### CLI ```bash npx bna-ui add input ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install lucide-react-native ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/input.tsx import { Icon } from '@/components/ui/icon'; import { Text } from '@/components/ui/text'; import { useColor } from '@/hooks/useColor'; import { BORDER_RADIUS, CORNERS, FONT_SIZE, HEIGHT } from '@/theme/globals'; import { LucideProps } from 'lucide-react-native'; import React, { forwardRef, ReactElement, useState } from 'react'; import { Pressable, TextInput, TextInputProps, TextStyle, View, ViewStyle, } from 'react-native'; export interface InputProps extends Omit { label?: string; error?: string; icon?: React.ComponentType; rightComponent?: React.ReactNode | (() => React.ReactNode); containerStyle?: ViewStyle; inputStyle?: TextStyle; labelStyle?: TextStyle; errorStyle?: TextStyle; variant?: 'filled' | 'outline'; disabled?: boolean; type?: 'input' | 'textarea'; placeholder?: string; rows?: number; // Only used when type="textarea" } export const Input = forwardRef( ( { label, error, icon, rightComponent, containerStyle, inputStyle, labelStyle, errorStyle, variant = 'filled', disabled = false, type = 'input', rows = 4, onFocus, onBlur, placeholder, ...props }, ref ) => { const [isFocused, setIsFocused] = useState(false); // Theme colors const cardColor = useColor('card'); const textColor = useColor('text'); const muted = useColor('textMuted'); const borderColor = useColor('border'); const primary = useColor('primary'); const danger = useColor('red'); const isTextarea = type === 'textarea'; // Calculate height based on type const getHeight = () => { if (isTextarea) { return rows * 20 + 32; // Approximate line height + padding } return HEIGHT; }; // Variant styles const getVariantStyle = (): ViewStyle => { const baseStyle: ViewStyle = { borderRadius: isTextarea ? BORDER_RADIUS : CORNERS, flexDirection: isTextarea ? 'column' : 'row', alignItems: isTextarea ? 'stretch' : 'center', minHeight: getHeight(), paddingHorizontal: 16, paddingVertical: isTextarea ? 12 : 0, }; switch (variant) { case 'outline': return { ...baseStyle, borderWidth: 1, borderColor: error ? danger : isFocused ? primary : borderColor, backgroundColor: 'transparent', }; case 'filled': default: return { ...baseStyle, borderWidth: 1, borderColor: error ? danger : cardColor, backgroundColor: disabled ? muted + '20' : cardColor, }; } }; const getInputStyle = (): TextStyle => ({ flex: 1, fontSize: FONT_SIZE, lineHeight: isTextarea ? 20 : undefined, color: disabled ? muted : error ? danger : textColor, paddingVertical: 0, // Remove default padding textAlignVertical: isTextarea ? 'top' : 'center', }); const handleFocus = (e: any) => { setIsFocused(true); onFocus?.(e); }; const handleBlur = (e: any) => { setIsFocused(false); onBlur?.(e); }; // Render right component - supports both direct components and functions const renderRightComponent = () => { if (!rightComponent) return null; // If it's a function, call it. Otherwise, render directly return typeof rightComponent === 'function' ? rightComponent() : rightComponent; }; const renderInputContent = () => ( {/* Input Container */} { if (!disabled && ref && 'current' in ref && ref.current) { ref.current.focus(); } }} disabled={disabled} > {isTextarea ? ( // Textarea Layout (Column) <> {/* Header section with icon, label, and right component */} {(icon || label || rightComponent) && ( {/* Left section - Icon + Label */} {icon && ( )} {label && ( {label} )} {/* Right Component */} {renderRightComponent()} )} {/* TextInput section */} ) : ( // Input Layout (Row) {/* Left section - Icon + Label (fixed width to simulate grid column) */} {icon && ( )} {label && ( {label} )} {/* TextInput section - takes remaining space */} {/* Right Component */} {renderRightComponent()} )} {/* Error Message */} {error && ( {error} )} ); return renderInputContent(); } ); export interface GroupedInputProps { children: React.ReactNode; containerStyle?: ViewStyle; title?: string; titleStyle?: TextStyle; } export const GroupedInput = ({ children, containerStyle, title, titleStyle, }: GroupedInputProps) => { const border = useColor('border'); const background = useColor('card'); const danger = useColor('red'); const childrenArray = React.Children.toArray(children); const errors = childrenArray .filter( (child): child is ReactElement => React.isValidElement(child) && !!(child.props as any).error ) .map((child) => child.props.error); const renderGroupedContent = () => ( {!!title && ( {title} )} {childrenArray.map((child, index) => ( {child} ))} {errors.length > 0 && ( {errors.map((error, i) => ( {error} ))} )} ); return renderGroupedContent(); }; export interface GroupedInputItemProps extends Omit { label?: string; error?: string; icon?: React.ComponentType; rightComponent?: React.ReactNode | (() => React.ReactNode); inputStyle?: TextStyle; labelStyle?: TextStyle; errorStyle?: TextStyle; disabled?: boolean; type?: 'input' | 'textarea'; rows?: number; // Only used when type="textarea" } export const GroupedInputItem = forwardRef( ( { label, error, icon, rightComponent, inputStyle, labelStyle, errorStyle, disabled, type = 'input', rows = 3, onFocus, onBlur, placeholder, ...props }, ref ) => { const [isFocused, setIsFocused] = useState(false); const text = useColor('text'); const muted = useColor('textMuted'); const primary = useColor('primary'); const danger = useColor('red'); const isTextarea = type === 'textarea'; const handleFocus = (e: any) => { setIsFocused(true); onFocus?.(e); }; const handleBlur = (e: any) => { setIsFocused(false); onBlur?.(e); }; const renderRightComponent = () => { if (!rightComponent) return null; return typeof rightComponent === 'function' ? rightComponent() : rightComponent; }; const renderItemContent = () => ( ref && 'current' in ref && ref.current?.focus()} disabled={disabled} style={{ opacity: disabled ? 0.6 : 1 }} > {isTextarea ? ( // Textarea Layout (Column) <> {/* Header section with icon, label, and right component */} {(icon || label || rightComponent) && ( {/* Icon & Label */} {icon && ( )} {label && ( {label} )} {/* Right Component */} {renderRightComponent()} )} {/* Textarea Input */} ) : ( // Input Layout (Row) {/* Icon & Label */} {icon && ( )} {label && ( {label} )} {/* Input */} {/* Right Component */} {renderRightComponent()} )} ); return renderItemContent(); } ); ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { Input, GroupedInput, GroupedInputItem } from '@/components/ui/input'; ``` ```tsx ``` ## Examples #### Default **Example:** A basic input with label and placeholder ```tsx // components/demo/input/input-demo.tsx import { Input } from '@/components/ui/input'; import { User } from 'lucide-react-native'; import React from 'react'; export function InputDemo() { return ( ); } ``` #### With Icons **Example:** Inputs with left-side icons ```tsx // components/demo/input/input-icons.tsx import { Input } from '@/components/ui/input'; import { View } from '@/components/ui/view'; import { Lock, Mail, Phone, Search } from 'lucide-react-native'; import React from 'react'; export function InputIcons() { return ( ); } ``` #### Variants **Example:** Different input variants - filled and outline ```tsx // components/demo/input/input-variants.tsx import { Input } from '@/components/ui/input'; import { View } from '@/components/ui/view'; import { Mail, User } from 'lucide-react-native'; import React from 'react'; export function InputVariants() { return ( ); } ``` #### Validation States **Example:** Inputs with error states and validation messages ```tsx // components/demo/input/input-validation.tsx import { Input } from '@/components/ui/input'; import { View } from '@/components/ui/view'; import { Lock, Mail } from 'lucide-react-native'; import React, { useState } from 'react'; export function InputValidation() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const emailError = email && !email.includes('@') ? 'Please enter a valid email address' : ''; const passwordError = password && password.length < 6 ? 'Password must be at least 6 characters' : ''; return ( ); } ``` #### Right Components **Example:** Inputs with buttons, icons, or custom components on the right ```tsx // components/demo/input/input-right-components.tsx import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { Copy, Eye, EyeOff, Search } from 'lucide-react-native'; import React, { useState } from 'react'; import { Pressable } from 'react-native'; export function InputRightComponents() { const muted = useColor('mutedForeground'); const [copied, setCopied] = useState(false); const [showPassword, setShowPassword] = useState(false); const handleCopy = () => { setCopied(true); setTimeout(() => setCopied(false), 2000); }; return ( Go } /> setShowPassword(!showPassword)}> {showPassword ? ( ) : ( )} } /> {copied ? 'Copied!' : 'Copy'} } /> ); } ``` #### Disabled State **Example:** Disabled inputs with reduced opacity ```tsx // components/demo/input/input-disabled.tsx import { Input } from '@/components/ui/input'; import { View } from '@/components/ui/view'; import { Mail, User } from 'lucide-react-native'; import React from 'react'; export function InputDisabled() { return ( ); } ``` #### Grouped Inputs **Example:** Multiple inputs grouped together in a card-like container ```tsx // components/demo/input/input-grouped.tsx import { GroupedInput, GroupedInputItem } from '@/components/ui/input'; import { Mail, MapPin, Phone, User } from 'lucide-react-native'; import React from 'react'; export function InputGrouped() { return ( ); } ``` #### Form Example **Example:** Complete form example with various input types ```tsx // components/demo/input/input-form.tsx import { Button } from '@/components/ui/button'; import { GroupedInput, GroupedInputItem, Input } from '@/components/ui/input'; import { View } from '@/components/ui/view'; import { Calendar, CreditCard, Lock, Mail, Phone, User, } from 'lucide-react-native'; import React, { useState } from 'react'; export function InputForm() { const [formData, setFormData] = useState({ firstName: '', lastName: '', email: '', password: '', confirmPassword: '', phone: '', cardNumber: '', expiryDate: '', cvv: '', }); const [errors, setErrors] = useState>({}); const validateForm = () => { const newErrors: Record = {}; if (!formData.firstName) newErrors.firstName = 'First name is required'; if (!formData.email) newErrors.email = 'Email is required'; else if (!formData.email.includes('@')) newErrors.email = 'Invalid email format'; if (!formData.password) newErrors.password = 'Password is required'; else if (formData.password.length < 6) newErrors.password = 'Password must be at least 6 characters'; if (formData.password !== formData.confirmPassword) newErrors.confirmPassword = 'Passwords do not match'; setErrors(newErrors); return Object.keys(newErrors).length === 0; }; const handleSubmit = () => { if (validateForm()) { alert('Form submitted successfully!'); } }; return ( setFormData((prev) => ({ ...prev, firstName: text })) } error={errors.firstName} /> setFormData((prev) => ({ ...prev, lastName: text })) } /> setFormData((prev) => ({ ...prev, email: text })) } error={errors.email} keyboardType='email-address' /> setFormData((prev) => ({ ...prev, phone: text })) } keyboardType='phone-pad' /> setFormData((prev) => ({ ...prev, password: text })) } error={errors.password} secureTextEntry variant='outline' /> setFormData((prev) => ({ ...prev, confirmPassword: text })) } error={errors.confirmPassword} secureTextEntry variant='outline' /> setFormData((prev) => ({ ...prev, cardNumber: text })) } keyboardType='numeric' /> setFormData((prev) => ({ ...prev, expiryDate: text })) } keyboardType='numeric' /> setFormData((prev) => ({ ...prev, cvv: text })) } keyboardType='numeric' /> ); } ``` ## API Reference ### Input The main input component with label, validation, and icon support. Extends all `TextInputProps` except `style`. | Prop | Type | Default | Description | | ---------------- | ---------------------------------- | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `label` | `string` | - | Label text displayed inside the input | | `error` | `string` | - | Error message to display below the input | | `icon` | `React.ComponentType` | - | Lucide icon component to display on the left | | `rightComponent` | `ReactNode \| (() => ReactNode)` | - | Component or function returning component for right side | | `containerStyle` | `ViewStyle` | - | Style for the outer container | | `inputStyle` | `TextStyle` | - | Style for the text input | | `labelStyle` | `TextStyle` | - | Style for the label text | | `errorStyle` | `TextStyle` | - | Style for the error message | | `variant` | `'filled' \| 'outline'` | `'filled'` | Visual variant of the input | | `disabled` | `boolean` | `false` | Whether the input is disabled | | `type` | `'input' \| 'textarea'` | `'input'` | Switches the layout to a multiline textarea when set to `'textarea'`. | | `rows` | `number` | `4` | Number of visible text lines. Only used when `type` is `"textarea"`. | | `placeholder` | `string` | `'Type your message...'` when `type` is `"textarea"` | Placeholder text shown when the input is empty. `type="textarea"` falls back to a default when omitted; `type="input"` has no fallback. | ### GroupedInput Container component for grouping multiple inputs together. | Prop | Type | Description | | ---------------- | ----------- | ------------------------------------------- | | `children` | `ReactNode` | Child components (usually GroupedInputItem) | | `containerStyle` | `ViewStyle` | Style for the container | | `title` | `string` | Optional title for the group | | `titleStyle` | `TextStyle` | Style for the title text | ### GroupedInputItem Input component designed to be used within GroupedInput. Extends all `TextInputProps` except `style`. | Prop | Type | Default | Description | | ---------------- | ---------------------------------- | --------- | --------------------------------------------------------------------- | | `label` | `string` | - | Label text displayed inside the input | | `error` | `string` | - | Error message (displayed at group level) | | `icon` | `React.ComponentType` | - | Lucide icon component to display on the left | | `rightComponent` | `ReactNode \| (() => ReactNode)` | - | Component or function returning component for right side | | `inputStyle` | `TextStyle` | - | Style for the text input | | `labelStyle` | `TextStyle` | - | Style for the label text | | `errorStyle` | `TextStyle` | - | Style for the error message | | `disabled` | `boolean` | `false` | Whether the input is disabled | | `type` | `'input' \| 'textarea'` | `'input'` | Switches the layout to a multiline textarea when set to `'textarea'`. | | `rows` | `number` | `3` | Number of visible text lines. Only used when `type` is `"textarea"`. | ## Accessibility The Input component is built with accessibility in mind: - Proper focus management and keyboard navigation - Screen reader support with semantic labeling - High contrast support for error states - Proper touch targets for mobile devices - Support for dynamic text sizing - Keyboard shortcuts and hardware keyboard support ## Styling The Input component uses your theme colors and can be customized: - `filled` variant: Uses card background with subtle borders - `outline` variant: Transparent background with prominent borders - Error states: Uses danger/red theme color - Focus states: Uses primary theme color - Disabled states: Reduced opacity with muted colors ## Best Practices - Use clear, descriptive labels - Provide helpful placeholder text - Show validation errors immediately after user interaction - Group related inputs using GroupedInput - Use appropriate input types (email, password, etc.) - Consider using icons to clarify input purpose - Ensure sufficient contrast for accessibility # Link > A navigation component that handles both internal and external links with customizable browser behavior. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/link - Markdown: https://ui.ahmedbna.com/docs/components/link.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/link.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/link.json - Install: `npx bna-ui add link` - npm dependencies: `expo-router`, `expo-web-browser` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `text` - Preview recording: https://demo.ahmedbna.com/0183-link-demo.MP4 --- **Example:** A basic link component with internal and external navigation ```tsx // components/demo/link/link-demo.tsx import { Link } from '@/components/ui/link'; import { View } from '@/components/ui/view'; import React from 'react'; export function LinkDemo() { return ( Go to Profile Settings User Details ); } ``` ## Installation ### CLI ```bash npx bna-ui add link ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install expo-router expo-web-browser ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/link.tsx import { Text } from '@/components/ui/text'; import { Link as ERLink, Href } from 'expo-router'; import { openBrowserAsync } from 'expo-web-browser'; import { type ComponentProps } from 'react'; import { Linking, Platform } from 'react-native'; export interface LinkProps extends Omit, 'href'> { href: Href; asChild?: boolean; browser?: 'in-app' | 'external'; children: React.ReactNode; } // Helper function to determine if URL is external const isExternalUrl = (href: Href): boolean => { // If href is an object, it's always internal navigation if (typeof href === 'object') { return false; } // Check if string href is external return ( href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:') || href.startsWith('tel:') || href.startsWith('sms:') || href.startsWith('whatsapp:') || href.startsWith('ftp://') || href.startsWith('file://') ); }; // Helper function to determine if URL should use native app (not browser) const isNativeAppUrl = (href: string): boolean => { return ( href.startsWith('mailto:') || href.startsWith('tel:') || href.startsWith('sms:') || href.startsWith('whatsapp:') ); }; // Helper function to convert href to string for external links const getHrefString = (href: Href): string => { if (typeof href === 'string') { return href; } // For object hrefs, we shouldn't convert to string for external use // This should only be called for external URLs (which are always strings) throw new Error('Cannot convert object href to string for external use'); }; export function Link({ href, asChild = false, children, browser = 'in-app', ...rest }: LinkProps) { const isExternal = isExternalUrl(href); const handlePress = async (event: any) => { if (isExternal) { // Always prevent default for external links event.preventDefault(); const hrefString = getHrefString(href); if (Platform.OS !== 'web') { // Check if this is a native app URL (email, phone, etc.) if (isNativeAppUrl(hrefString)) { // Always use Linking.openURL for native app URLs try { const canOpen = await Linking.canOpenURL(hrefString); if (canOpen) { await Linking.openURL(hrefString); } else { console.warn(`Cannot open URL: ${hrefString}`); // Optionally show an alert to the user } } catch (error) { console.error('Error opening URL:', error); } } else { // For HTTP/HTTPS URLs, use browser preference if (browser === 'external') { // Open the link in external browser await Linking.openURL(hrefString); } else { // Open the link in in-app browser (default) try { await openBrowserAsync(hrefString); } catch (error) { console.error('Error opening browser:', error); // Fallback to external browser await Linking.openURL(hrefString); } } } } else { // On web platform if (isNativeAppUrl(hrefString)) { // For web, directly navigate to the URL (browser will handle it) window.location.href = hrefString; } else { // For HTTP/HTTPS URLs, open in new tab window.open(hrefString, '_blank'); } } } // For internal navigation, don't prevent default - let ERLink handle it }; // For external links, use a custom approach to avoid conflicts if (isExternal) { return ( {typeof children === 'string' ? ( {children} ) : ( children )} ); } // For internal links, use ERLink directly without custom onPress return ( {typeof children === 'string' ? ( {children} ) : ( children )} ); } ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { Link } from '@/components/ui/link'; ``` ```tsx Go to Profile ``` ```tsx Open in Browser ``` ## Examples #### Default Internal Navigation **Example:** Basic internal navigation links ```tsx // components/demo/link/link-demo.tsx import { Link } from '@/components/ui/link'; import { View } from '@/components/ui/view'; import React from 'react'; export function LinkDemo() { return ( Go to Profile Settings User Details ); } ``` #### External Links **Example:** Links that open external URLs ```tsx // components/demo/link/link-external.tsx import { Link } from '@/components/ui/link'; import { View } from '@/components/ui/view'; import React from 'react'; export function LinkExternal() { return ( Visit GitHub Expo Documentation React Native Docs ); } ``` #### Browser Options **Example:** Links with different browser opening behaviors ```tsx // components/demo/link/link-browser.tsx import { Link } from '@/components/ui/link'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function LinkBrowser() { return ( In-App Browser (Default) Open GitHub in-app Open Expo docs in-app External Browser Open GitHub externally Open Expo docs externally ); } ``` #### With Custom Children **Example:** Links with custom child components instead of text ```tsx // components/demo/link/link-custom.tsx import { Button } from '@/components/ui/button'; import { Link } from '@/components/ui/link'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { ExternalLink, HomeIcon, Mail } from 'lucide-react-native'; import React from 'react'; export function LinkCustom() { return ( External Link ); } ``` #### Different Link Types **Example:** Various types of links including mailto and tel ```tsx // components/demo/link/link-types.tsx import { Link } from '@/components/ui/link'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function LinkTypes() { return ( Internal Navigation Home Page About Us Product Details External URLs Google Example Site Communication Links Send Email Call Phone Email with Subject Send SMS ); } ``` #### Styled Links **Example:** Links with custom styling and variants ```tsx // components/demo/link/link-styled.tsx import { Link } from '@/components/ui/link'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function LinkStyled() { return ( Default Styled Default Link Style External Link Custom Text Styling Red Bold Link Green Italic Link Purple Uppercase Inline Links This is a paragraph with an inline link that flows naturally with the text. You can also have{' '} external inline links in your content. ); } ``` #### Button-Style Links **Example:** Links styled as buttons for navigation ```tsx // components/demo/link/link-buttons.tsx import { Button } from '@/components/ui/button'; import { Link } from '@/components/ui/link'; import { View } from '@/components/ui/view'; import { ExternalLink, Settings, User } from 'lucide-react-native'; import React from 'react'; export function LinkButtons() { return ( ); } ``` ## API Reference ### Link The main link component that handles navigation and external URL opening. | Prop | Type | Default | Description | | ---------- | ------------------------ | ---------- | ------------------------------------------------------------------ | | `href` | `Href` | - | The destination URL or route. Can be string or route object. | | `browser` | `'in-app' \| 'external'` | `'in-app'` | How external links should open (in-app browser vs system browser). | | `children` | `ReactNode` | - | The content of the link. Can be text or custom components. | All other props from `expo-router`'s `Link` component are also supported. ### Href Types The `href` prop accepts the following formats: - **Internal routes**: `"/profile"`, `"/settings"`, `{ pathname: "/user", params: { id: "123" } }` - **External URLs**: `"https://example.com"`, `"http://example.com"` - **Email links**: `"mailto:user@example.com"` - **Phone links**: `"tel:+1234567890"` `href`'s `Href` type comes directly from `expo-router`. For it to type-check internal routes against your app's actual file-based routes (autocomplete and compile errors on typos), enable typed routes in `app.json`: ```json { "expo": { "experiments": { "typedRoutes": true } } } ``` Without it, `href` still works at runtime — it just widens to `string`, losing route-name validation. ## Browser Behavior ### In-App Browser (Default) When `browser="in-app"` (default), external links open in an in-app browser: - **Mobile**: Uses `expo-web-browser` for a seamless in-app experience - **Web**: Opens in a new tab ### External Browser When `browser="external"`, external links open in the system browser: - **Mobile**: Uses the device's default browser app - **Web**: Opens in a new tab ## Accessibility The Link component maintains accessibility features: - Proper semantic link structure for screen readers - Keyboard navigation support - Focus management - ARIA attributes when using custom children - Text decoration for visual link indication ## Platform Considerations ### React Native - Internal navigation uses Expo Router's navigation system - External links use either `expo-web-browser` or `Linking` API - Haptic feedback can be added through custom styling ### Web - Internal navigation uses client-side routing - External links open in new tabs - Standard web link behavior and styling apply ## Best Practices 1. **Use descriptive link text** that clearly indicates the destination 2. **Choose appropriate browser behavior** based on user context 3. **Test on both platforms** to ensure consistent behavior 4. **Consider loading states** for external links that might take time to open 5. **Use custom children sparingly** - text links are more accessible # MediaPicker > A versatile component for selecting images and videos from device gallery or camera with preview capabilities. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/media-picker - Markdown: https://ui.ahmedbna.com/docs/components/media-picker.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/media-picker.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/media-picker.json - Install: `npx bna-ui add media-picker` - npm dependencies: `expo-haptics`, `expo-image`, `expo-image-picker`, `expo-media-library`, `lucide-react-native`, `react-native-reanimated`, `react-native-svg`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `useHaptics`, `text`, `view`, `icon`, `spinner`, `button` - Preview recording: https://demo.ahmedbna.com/0190-media-picker-demo.MP4 --- **Example:** A basic media picker with image and video selection ```tsx // components/demo/media-picker/media-picker-demo.tsx import { MediaPicker } from '@/components/ui/media-picker'; import React from 'react'; export function MediaPickerDemo() { return ( { console.log('Selected assets:', assets); }} onError={(error) => { console.error('Media picker error:', error); }} /> ); } ``` ## Installation ### CLI ```bash npx bna-ui add media-picker ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install expo-image expo-image-picker expo-media-library lucide-react-native ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/media-picker.tsx import { Button, ButtonSize, ButtonVariant } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { CORNERS, FONT_SIZE } from '@/theme/globals'; import { Image as ExpoImage } from 'expo-image'; import * as ImagePicker from 'expo-image-picker'; import * as MediaLibrary from 'expo-media-library'; import { LucideProps, Video, X } from 'lucide-react-native'; import React, { forwardRef, useEffect, useRef, useState } from 'react'; import { Dimensions, FlatList, Linking, Modal, Pressable, View as RNView, StyleSheet, TouchableOpacity, ViewStyle, } from 'react-native'; export type MediaType = 'image' | 'video' | 'all'; export type MediaQuality = 'low' | 'medium' | 'high'; export interface MediaAsset { id: string; uri: string; type: 'image' | 'video'; width?: number; height?: number; duration?: number; filename?: string; fileSize?: number; } export interface MediaPickerProps { children?: React.ReactNode; style?: ViewStyle; size?: ButtonSize; variant?: ButtonVariant; icon?: React.ComponentType; disabled?: boolean; mediaType?: MediaType; multiple?: boolean; maxSelection?: number; quality?: MediaQuality; buttonText?: string; placeholder?: string; gallery?: boolean; showPreview?: boolean; previewSize?: number; selectedAssets?: MediaAsset[]; onSelectionChange?: (assets: MediaAsset[]) => void; onError?: (error: string) => void; } const { width: screenWidth } = Dimensions.get('window'); // Helper function to compare arrays of MediaAssets const arraysEqual = (a: MediaAsset[], b: MediaAsset[]): boolean => { if (a.length !== b.length) return false; return a.every((item, index) => { const bItem = b[index]; return ( item.id === bItem.id && item.uri === bItem.uri && item.type === bItem.type ); }); }; export const MediaPicker = forwardRef( ( { children, mediaType = 'all', multiple = false, gallery = false, maxSelection = 10, quality = 'high', onSelectionChange, onError, buttonText, showPreview = true, previewSize = 80, style, variant, size, icon, disabled = false, selectedAssets = [], }, ref ) => { const [assets, setAssets] = useState(selectedAssets); const [isGalleryVisible, setIsGalleryVisible] = useState(false); // SDK 56 replaced the eagerly-populated `Asset` object with a lazy handle // whose fields are async getters. `AssetInfo` is the resolved shape, so the // gallery resolves once on load and the render path stays synchronous. const [galleryAssets, setGalleryAssets] = useState< MediaLibrary.AssetInfo[] >([]); const [hasPermission, setHasPermission] = useState(null); const [canAskAgain, setCanAskAgain] = useState(true); // Use ref to track previous selectedAssets to avoid unnecessary updates const prevSelectedAssetsRef = useRef(selectedAssets); // Theme colors const cardColor = useColor('card'); const borderColor = useColor('border'); const textColor = useColor('text'); const mutedColor = useColor('mutedForeground'); const primaryColor = useColor('primary'); const secondary = useColor('secondary'); // Update internal state when selectedAssets prop changes (with proper comparison) useEffect(() => { // Only update if the arrays are actually different if (!arraysEqual(prevSelectedAssetsRef.current, selectedAssets)) { setAssets(selectedAssets); prevSelectedAssetsRef.current = selectedAssets; } }, [selectedAssets]); // Requested lazily, from the picker button press, rather than eagerly on // mount — avoids surfacing the OS permission prompt before the user has // expressed any intent to pick media. const requestPermissions = async (): Promise<{ granted: boolean; canAskAgain: boolean; }> => { try { const { status, canAskAgain: canAsk } = await MediaLibrary.requestPermissionsAsync(); const granted = status === 'granted'; setHasPermission(granted); setCanAskAgain(canAsk); if (!granted) { onError?.( canAsk ? 'Media library permission is required to access photos and videos' : 'Media library permission was denied. Enable it in Settings to continue.' ); } return { granted, canAskAgain: canAsk }; } catch (error) { onError?.('Failed to request permissions'); setHasPermission(false); return { granted: false, canAskAgain: true }; } }; const loadGalleryAssets = async () => { if (!hasPermission) return; try { const query = new MediaLibrary.Query(); if (mediaType === 'image') { query.eq( MediaLibrary.AssetField.MEDIA_TYPE, MediaLibrary.MediaType.IMAGE ); } else if (mediaType === 'video') { query.eq( MediaLibrary.AssetField.MEDIA_TYPE, MediaLibrary.MediaType.VIDEO ); } else { query.within(MediaLibrary.AssetField.MEDIA_TYPE, [ MediaLibrary.MediaType.IMAGE, MediaLibrary.MediaType.VIDEO, ]); } const found = await query .orderBy({ key: MediaLibrary.AssetField.CREATION_TIME, ascending: false, }) .limit(100) .exe(); setGalleryAssets(await Promise.all(found.map((a) => a.getInfo()))); } catch (error) { onError?.('Failed to load gallery assets'); } }; const pickFromGallery = async () => { if (!hasPermission) { if (hasPermission === false && !canAskAgain) { Linking.openSettings(); return; } const { granted, canAskAgain: canAsk } = await requestPermissions(); if (!granted) { if (!canAsk) { Linking.openSettings(); } return; } } if (gallery) { await loadGalleryAssets(); setIsGalleryVisible(true); return; } try { const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: mediaType === 'image' ? ['images'] : mediaType === 'video' ? ['videos'] : ['images', 'videos'], allowsMultipleSelection: multiple, quality: quality === 'high' ? 1 : quality === 'medium' ? 0.7 : 0.3, selectionLimit: multiple ? maxSelection : 1, }); if (!result.canceled && result.assets) { const newAssets = result.assets.map((asset, index) => ({ id: `gallery_${Date.now()}_${index}`, uri: asset.uri, type: asset.type === 'video' ? ('video' as const) : ('image' as const), width: asset.width, height: asset.height, duration: asset.duration || undefined, filename: asset.fileName || undefined, fileSize: asset.fileSize, })); handleAssetSelection(newAssets); } } catch (error) { onError?.('Failed to pick media from gallery'); } }; const handleAssetSelection = (newAssets: MediaAsset[]) => { let updatedAssets: MediaAsset[]; if (multiple) { updatedAssets = [...assets, ...newAssets].slice(0, maxSelection); } else { updatedAssets = newAssets; } setAssets(updatedAssets); prevSelectedAssetsRef.current = updatedAssets; // Update ref to prevent loop onSelectionChange?.(updatedAssets); }; const handleGalleryAssetSelect = async ( galleryAsset: MediaLibrary.AssetInfo ) => { try { const newAsset: MediaAsset = { id: galleryAsset.id, uri: galleryAsset.uri, type: galleryAsset.mediaType === MediaLibrary.MediaType.VIDEO ? 'video' : 'image', width: galleryAsset.width, height: galleryAsset.height, duration: galleryAsset.duration || undefined, filename: galleryAsset.filename, }; if (multiple) { const isAlreadySelected = assets.some( (asset) => asset.id === newAsset.id ); if (isAlreadySelected) { const filteredAssets = assets.filter( (asset) => asset.id !== newAsset.id ); setAssets(filteredAssets); prevSelectedAssetsRef.current = filteredAssets; // Update ref onSelectionChange?.(filteredAssets); } else if (assets.length < maxSelection) { const updatedAssets = [...assets, newAsset]; setAssets(updatedAssets); prevSelectedAssetsRef.current = updatedAssets; // Update ref onSelectionChange?.(updatedAssets); } } else { const newAssets = [newAsset]; setAssets(newAssets); prevSelectedAssetsRef.current = newAssets; // Update ref onSelectionChange?.(newAssets); setIsGalleryVisible(false); } } catch (error) { onError?.('Failed to select asset'); } }; const removeAsset = (assetId: string) => { const filteredAssets = assets.filter((asset) => asset.id !== assetId); setAssets(filteredAssets); prevSelectedAssetsRef.current = filteredAssets; // Update ref onSelectionChange?.(filteredAssets); }; const renderPreviewItem = ({ item }: { item: MediaAsset }) => ( {item.type === 'video' && ( )} removeAsset(item.id)} > ); const renderGalleryItem = ({ item }: { item: MediaLibrary.AssetInfo }) => { const isSelected = assets.some((asset) => asset.id === item.id); const itemWidth = screenWidth / 3 - 4; return ( handleGalleryAssetSelect(item)} > {item.mediaType === MediaLibrary.MediaType.VIDEO && ( )} {multiple && isSelected && ( {assets.findIndex((asset) => asset.id === item.id) + 1} )} ); }; return ( {children ? ( children ) : ( )} {showPreview && assets.length > 0 && ( item.id} horizontal showsHorizontalScrollIndicator={false} style={styles.previewContainer} contentContainerStyle={styles.previewContent} /> )} {gallery && ( {buttonText || `Select ${ mediaType === 'all' ? 'Media' : mediaType === 'image' ? 'Images' : 'Videos' }`} {multiple && ( {assets.length}/{maxSelection} )} item.id} numColumns={3} contentContainerStyle={styles.galleryContent} /> )} ); } ); const styles = StyleSheet.create({ compactButton: { width: 60, height: 60, borderRadius: CORNERS, borderWidth: 1, borderStyle: 'dashed', alignItems: 'center', justifyContent: 'center', }, disabled: { opacity: 0.5, }, previewContainer: { marginTop: 12, }, previewContent: { paddingHorizontal: 4, }, previewItem: { marginHorizontal: 4, borderRadius: 8, borderWidth: 1, overflow: 'hidden', position: 'relative', }, previewImage: { borderRadius: 8, }, videoIndicator: { position: 'absolute', top: 8, left: 8, backgroundColor: 'rgba(0, 0, 0, 0.6)', borderRadius: 12, padding: 4, }, removeButton: { position: 'absolute', top: 6, right: 6, width: 20, height: 20, borderRadius: 10, alignItems: 'center', justifyContent: 'center', }, modalContainer: { flex: 1, }, modalHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: 16, borderBottomWidth: StyleSheet.hairlineWidth, }, modalActions: { flexDirection: 'row', alignItems: 'center', gap: 16, }, selectionCount: { fontSize: FONT_SIZE, fontWeight: '500', }, closeButton: { padding: 4, }, galleryContent: { padding: 2, }, galleryItem: { margin: 1, borderRadius: 4, overflow: 'hidden', position: 'relative', }, galleryImage: { width: '100%', height: '100%', }, selectedIndicator: { position: 'absolute', top: 8, right: 8, width: 24, height: 24, borderRadius: 12, alignItems: 'center', justifyContent: 'center', }, }); MediaPicker.displayName = 'MediaPicker'; ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { MediaPicker } from '@/components/ui/media-picker'; ``` ```tsx console.log(assets)} /> ``` ## Examples #### Default **Example:** A basic media picker with image and video selection ```tsx // components/demo/media-picker/media-picker-demo.tsx import { MediaPicker } from '@/components/ui/media-picker'; import React from 'react'; export function MediaPickerDemo() { return ( { console.log('Selected assets:', assets); }} onError={(error) => { console.error('Media picker error:', error); }} /> ); } ``` #### Image Only **Example:** Media picker configured for images only ```tsx // components/demo/media-picker/media-picker-images.tsx import { MediaPicker } from '@/components/ui/media-picker'; import { Image } from 'lucide-react-native'; import React from 'react'; export function MediaPickerImages() { return ( { console.log('Selected images:', assets); }} /> ); } ``` #### Video Only **Example:** Media picker configured for videos only ```tsx // components/demo/media-picker/media-picker-videos.tsx import { MediaPicker } from '@/components/ui/media-picker'; import { Video } from 'lucide-react-native'; import React from 'react'; export function MediaPickerVideos() { return ( { console.log('Selected videos:', assets); }} /> ); } ``` #### Multiple Selection **Example:** Media picker with multiple selection enabled ```tsx // components/demo/media-picker/media-picker-multiple.tsx import { MediaPicker } from '@/components/ui/media-picker'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { Plus } from 'lucide-react-native'; import React, { useState } from 'react'; export function MediaPickerMultiple() { const [selectedCount, setSelectedCount] = useState(0); return ( { setSelectedCount(assets.length); console.log('Selected assets:', assets); }} /> {selectedCount > 0 && ( {selectedCount} item{selectedCount !== 1 ? 's' : ''} selected )} ); } ``` #### Custom Gallery **Example:** Media picker with custom gallery modal ```tsx // components/demo/media-picker/media-picker-gallery.tsx import { MediaAsset, MediaPicker } from '@/components/ui/media-picker'; import { Folder } from 'lucide-react-native'; import React, { useState } from 'react'; export function MediaPickerGallery() { const [selected, setSelected] = useState([]); return ( ); } ``` #### With Preview **Example:** Media picker showing selected media previews ```tsx // components/demo/media-picker/media-picker-preview.tsx import { MediaAsset, MediaPicker } from '@/components/ui/media-picker'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { ImageIcon } from 'lucide-react-native'; import React, { useState } from 'react'; export function MediaPickerPreview() { const [assets, setAssets] = useState([]); return ( { setAssets(newAssets); console.log('Assets with preview:', newAssets); }} /> {assets.length > 0 && ( {assets.length} item{assets.length !== 1 ? 's' : ''} selected Types: {assets.map((a) => a.type).join(', ')} )} ); } ``` #### Quality Settings **Example:** Media picker with different quality settings ```tsx // components/demo/media-picker/media-picker-quality.tsx import { MediaPicker } from '@/components/ui/media-picker'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { Settings } from 'lucide-react-native'; import React from 'react'; export function MediaPickerQuality() { return ( High Quality { console.log('High quality assets:', assets); }} /> Medium Quality { console.log('Medium quality assets:', assets); }} /> Low Quality { console.log('Low quality assets:', assets); }} /> ); } ``` ## API Reference ### MediaPicker The main component for selecting media from device gallery or camera. | Prop | Type | Default | Description | | ------------------- | -------------------------------- | -------- | ------------------------------------------------------------- | | `children` | `ReactNode` | - | Custom trigger element. If not provided, uses default button. | | `style` | `ViewStyle` | - | Additional styles to apply to the container. | | `size` | `ButtonSize` | - | Size of the default button trigger. | | `variant` | `ButtonVariant` | - | Variant of the default button trigger. | | `icon` | `ComponentType` | - | Icon for the default button trigger. | | `disabled` | `boolean` | `false` | Whether the media picker is disabled. | | `mediaType` | `'image' \| 'video' \| 'all'` | `'all'` | Type of media to allow selection. | | `multiple` | `boolean` | `false` | Whether to allow multiple selection. | | `maxSelection` | `number` | `10` | Maximum number of items that can be selected. | | `quality` | `'low' \| 'medium' \| 'high'` | `'high'` | Quality of selected media. | | `buttonText` | `string` | - | Text for the default button trigger. | | `placeholder` | `string` | - | Placeholder text (currently unused). | | `gallery` | `boolean` | `false` | Whether to show custom gallery modal. | | `showPreview` | `boolean` | `true` | Whether to show preview of selected media. | | `previewSize` | `number` | `80` | Size of preview thumbnails in pixels. | | `selectedAssets` | `MediaAsset[]` | `[]` | Controlled selected assets. | | `onSelectionChange` | `(assets: MediaAsset[]) => void` | - | Callback when selection changes. | | `onError` | `(error: string) => void` | - | Callback when an error occurs. | ### MediaAsset The interface for media assets returned by the picker. | Prop | Type | Description | | ---------- | -------------------- | -------------------------------- | | `id` | `string` | Unique identifier for the asset. | | `uri` | `string` | Local URI of the selected media. | | `type` | `'image' \| 'video'` | Type of the media asset. | | `width` | `number?` | Width of the media in pixels. | | `height` | `number?` | Height of the media in pixels. | | `duration` | `number?` | Duration in seconds for videos. | | `filename` | `string?` | Original filename of the media. | | `fileSize` | `number?` | File size in bytes. | ## Permissions The MediaPicker component requires the following permissions: - **iOS**: `NSPhotoLibraryUsageDescription` in Info.plist - **Android**: `READ_EXTERNAL_STORAGE` permission Permission is requested when the picker button is first pressed, not on mount. If the user has permanently denied access, pressing the button again opens the device Settings app instead of re-prompting. Add the following plugins to your `app.json` so Expo generates the required native permission entries: ```json { "expo": { "plugins": ["expo-image-picker", "expo-media-library"] } } ``` ## Features - **Multiple Media Types**: Support for images, videos, or both - **Gallery Integration**: Custom gallery modal or system picker - **Preview Support**: Show thumbnails of selected media - **Quality Control**: Adjustable media quality settings - **Batch Selection**: Select multiple items with configurable limits - **Error Handling**: Comprehensive error handling and callbacks - **Accessibility**: Built-in accessibility features - **Theme Integration**: Respects your app's theme colors ## Accessibility The MediaPicker component is built with accessibility in mind: - Proper labeling for screen readers - Keyboard navigation support - High contrast support for buttons and indicators - Semantic structure for better navigation - Error announcements for screen readers ## Notes - The component uses `expo-image-picker` and `expo-media-library` for media selection - Permissions are automatically requested when needed - Selected assets are stored in memory during the session - Preview thumbnails are generated automatically - Video duration and file size information is included when available # Mode Toggle > An animated button component for switching between light and dark themes. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/mode-toggle - Markdown: https://ui.ahmedbna.com/docs/components/mode-toggle.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/mode-toggle.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/mode-toggle.json - Install: `npx bna-ui add mode-toggle` - npm dependencies: `expo-haptics`, `lucide-react-native`, `react-native-reanimated`, `react-native-svg`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useModeToggle`, `useColorScheme`, `colors`, `useColor`, `useHaptics`, `globals`, `text`, `view`, `icon`, `spinner`, `button` - Preview recording: https://demo.ahmedbna.com/0197-mode-toggle-demo.MP4 --- **Example:** Animated theme toggle button ```tsx // components/demo/mode-toggle/mode-toggle-demo.tsx import { ModeToggle } from '@/components/ui/mode-toggle'; import React from 'react'; export function ModeToggleDemo() { return ; } ``` ## Installation ### CLI ```bash npx bna-ui add mode-toggle ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install react-native-reanimated react-native-worklets lucide-react-native ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/mode-toggle.tsx import { Button, ButtonSize, ButtonVariant } from '@/components/ui/button'; import { Icon } from '@/components/ui/icon'; import { useModeToggle } from '@/hooks/useModeToggle'; import { Moon, Sun } from 'lucide-react-native'; import { useEffect, useState } from 'react'; import Animated, { runOnJS, useAnimatedStyle, useSharedValue, withTiming, } from 'react-native-reanimated'; type Props = { variant?: ButtonVariant; size?: ButtonSize; haptic?: boolean; }; export const ModeToggle = ({ variant = 'outline', size = 'icon', haptic = true, }: Props) => { const { toggleMode, isDark } = useModeToggle(); const rotation = useSharedValue(0); const scale = useSharedValue(1); const [showIcon, setShowIcon] = useState<'sun' | 'moon'>( isDark ? 'moon' : 'sun' ); useEffect(() => { // Animate icon change scale.value = withTiming(0, { duration: 150 }, () => { runOnJS(setShowIcon)(isDark ? 'moon' : 'sun'); scale.value = withTiming(1, { duration: 150 }); }); // Only rotate when switching to sun (sun rays spinning effect) if (!isDark) { rotation.value = withTiming(rotation.value + 180, { duration: 300 }); } }, [isDark]); const animatedStyle = useAnimatedStyle(() => { return { transform: [ { rotate: showIcon === 'sun' ? `${rotation.value}deg` : '0deg' }, { scale: scale.value }, ], }; }); return ( ); }; ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { ModeToggle } from '@/components/ui/mode-toggle'; ``` ```tsx ``` ## Examples #### Default **Example:** Animated theme toggle button ```tsx // components/demo/mode-toggle/mode-toggle-demo.tsx import { ModeToggle } from '@/components/ui/mode-toggle'; import React from 'react'; export function ModeToggleDemo() { return ; } ``` ## API Reference ### ModeToggle An animated button that toggles between light and dark themes. The component uses the `Button` component internally, so it inherits button styling and behavior. | Prop | Type | Default | Description | | --------- | --------------- | ----------- | ---------------------------------------------------------------------------------- | | `haptic` | `boolean` | `true` | Whether to trigger haptic feedback on press. Forwarded to the underlying `Button`. | | `variant` | `ButtonVariant` | `'outline'` | The visual variant passed through to the underlying `Button`. | | `size` | `ButtonSize` | `'icon'` | The size passed through to the underlying `Button`. | ## Animation Details ### Icon Transition - **Scale Animation**: Icons scale down to 0, change, then scale back to 1 - **Duration**: 150ms for each scale phase (300ms total) - **Icons**: Sun for light mode, Moon for dark mode ### Sun Rotation - **Rotation**: 180° rotation when switching to light mode - **Duration**: 300ms - **Effect**: Creates a spinning sun rays effect - **Timing**: Only rotates when switching to sun icon ## Theme Integration The component is a thin shell over the [`useModeToggle`](/docs/hooks/useModeToggle) hook — it takes `isDark` to pick the icon and `toggleMode` for the press handler, and owns nothing else: ```tsx const { toggleMode, isDark } = useModeToggle(); ``` That hook reads the mode from [`ModeProvider`](/docs/providers/mode-provider), so a provider has to be mounted above the toggle or it throws. Wrapping your app in [`ThemeProvider`](/docs/providers/theme-provider) is enough — it mounts one — and every scaffold from `npx bna-ui init` already does. ## Performance The component is optimized for smooth animations: - Uses `react-native-reanimated` for 60fps animations - Animations run on the UI thread - Minimal re-renders with `useSharedValue` - Efficient icon switching with `runOnJS` ## Accessibility The ModeToggle maintains accessibility: - Uses semantic button component - Screen readers announce theme changes - Maintains proper focus behavior - Works with keyboard navigation - Respects system accessibility settings ## Integration Example ```tsx // app/_layout.tsx import { ModeToggle } from '@/components/ui/mode-toggle'; import { ThemeProvider } from '@/providers/theme-provider'; export default function RootLayout() { return ( My App {/* Rest of your app */} ); } ``` Add `storage={SecureStore}` to `ThemeProvider` to have the choice survive a restart — see [`ModeProvider`](/docs/providers/mode-provider). ## Dependencies The component requires these utilities to function: - `useModeToggle`: Hook for theme switching logic - `Button`: Base button component - `Icon`: Themed icon wrapper - `ModeProvider`: Holds the mode, mounted for you by `ThemeProvider` # Onboarding > A customizable multi-step onboarding flow with smooth animations and gesture support. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/onboarding - Markdown: https://ui.ahmedbna.com/docs/components/onboarding.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/onboarding.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/onboarding.json - Install: `npx bna-ui add onboarding` - npm dependencies: `expo-haptics`, `lucide-react-native`, `react-native-gesture-handler`, `react-native-reanimated`, `react-native-svg`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `useHaptics`, `globals`, `text`, `view`, `icon`, `spinner`, `button` - Preview recording: https://demo.ahmedbna.com/0198-onboarding-demo.MP4 --- **Example:** A basic onboarding flow with multiple steps ```tsx // components/demo/onboarding/onboarding-demo.tsx import { Onboarding, useOnboarding } from '@/components/ui/onboarding'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; export const OnboardingPresets = { welcome: [ { id: 'welcome', title: 'Welcome to Our App', description: 'Discover amazing features and get started with your journey.', icon: 👋, }, { id: 'features', title: 'Powerful Features', description: 'Experience cutting-edge functionality designed to make your life easier.', icon: , }, { id: 'personalize', title: 'Personalize Your Experience', description: 'Customize the app to match your preferences and workflow.', icon: 🎨, }, { id: 'ready', title: "You're All Set!", description: "Everything is ready. Let's start exploring what you can achieve.", icon: 🚀, }, ], features: [ { id: 'organize', title: 'Stay Organized', description: 'Keep all your important information in one secure place.', icon: 📋, }, { id: 'collaborate', title: 'Collaborate Seamlessly', description: 'Work together with your team in real-time, anywhere.', icon: 🤝, }, { id: 'automate', title: 'Automate Your Workflow', description: 'Set up smart automations to save time and reduce errors.', icon: 🤖, }, ], security: [ { id: 'secure', title: 'Your Data is Secure', description: 'We use end-to-end encryption to keep your information safe.', icon: 🔒, }, { id: 'privacy', title: 'Privacy First', description: 'We never share your personal data with third parties.', icon: 🛡️, }, { id: 'control', title: "You're in Control", description: 'Manage your privacy settings and data preferences anytime.', icon: ⚙️, }, ], }; export function OnboardingDemo() { const { hasCompletedOnboarding, completeOnboarding, skipOnboarding } = useOnboarding(); if (hasCompletedOnboarding) { return ( Welcome Back! You've already completed the onboarding. ); } return ( ); } ``` ## Installation ### CLI ```bash npx bna-ui add onboarding ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install react-native-gesture-handler react-native-reanimated ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/onboarding.tsx import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { useColor } from '@/hooks/useColor'; import React, { useEffect, useRef, useState } from 'react'; import { AccessibilityInfo, ScrollView, StyleSheet, useWindowDimensions, View, ViewStyle, } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { runOnJS, useAnimatedStyle, useSharedValue, withSpring, } from 'react-native-reanimated'; export interface OnboardingStep { id: string; title: string; description: string; image?: React.ReactNode; icon?: React.ReactNode; backgroundColor?: string; } export interface OnboardingProps { steps: OnboardingStep[]; onComplete: () => void; onSkip?: () => void; showSkip?: boolean; showProgress?: boolean; swipeEnabled?: boolean; primaryButtonText?: string; skipButtonText?: string; nextButtonText?: string; backButtonText?: string; style?: ViewStyle; children?: React.ReactNode; } // Enhanced Onboarding Step Component for complex layouts interface OnboardingStepContentProps { step: OnboardingStep; isActive: boolean; children?: React.ReactNode; } export function Onboarding({ steps, onComplete, onSkip, showSkip = true, showProgress = true, swipeEnabled = true, primaryButtonText = 'Get Started', skipButtonText = 'Skip', nextButtonText = 'Next', backButtonText = 'Back', style, children, }: OnboardingProps) { const { width: screenWidth } = useWindowDimensions(); const [currentStep, setCurrentStep] = useState(0); const scrollViewRef = useRef(null); const translateX = useSharedValue(0); const backgroundColor = useColor('background'); const primaryColor = useColor('primary'); const mutedColor = useColor('mutedForeground'); const isLastStep = currentStep === steps.length - 1; const isFirstStep = currentStep === 0; useEffect(() => { const step = steps[currentStep]; if (!step) return; AccessibilityInfo.announceForAccessibility( `${step.title}. Step ${currentStep + 1} of ${steps.length}.` ); // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentStep]); const handleNext = () => { if (isLastStep) { onComplete(); } else { const nextStep = currentStep + 1; setCurrentStep(nextStep); scrollViewRef.current?.scrollTo({ x: nextStep * screenWidth, animated: true, }); } }; const handleBack = () => { if (!isFirstStep) { const prevStep = currentStep - 1; setCurrentStep(prevStep); scrollViewRef.current?.scrollTo({ x: prevStep * screenWidth, animated: true, }); } }; const handleSkip = () => { if (onSkip) { onSkip(); } else { onComplete(); } }; // Modern gesture handling with Gesture API const panGesture = Gesture.Pan() .enabled(swipeEnabled) .onUpdate((event) => { translateX.value = event.translationX; }) .onEnd((event) => { const { translationX, velocityX } = event; const shouldSwipe = Math.abs(translationX) > screenWidth * 0.3 || Math.abs(velocityX) > 500; if (shouldSwipe) { if (translationX > 0 && !isFirstStep) { // Swipe right - go back runOnJS(handleBack)(); } else if (translationX < 0 && !isLastStep) { // Swipe left - go next runOnJS(handleNext)(); } } translateX.value = withSpring(0); }); const animatedStyle = useAnimatedStyle(() => ({ transform: [{ translateX: translateX.value }], })); const renderProgressDots = () => { if (!showProgress) return null; return ( {steps.map((_, index) => ( ))} ); }; const renderStep = (step: OnboardingStep, index: number) => { const isActive = index === currentStep; return ( {step.image && ( {step.image} )} {step.icon && !step.image && ( {step.icon} )} {step.title} {step.description} {children && {children}} ); }; return ( { const newStep = Math.round( event.nativeEvent.contentOffset.x / screenWidth ); setCurrentStep(newStep); }} > {steps.map((step, index) => renderStep(step, index))} {/* Progress Dots */} {renderProgressDots()} {/* Skip Button */} {showSkip && !isLastStep && ( )} {/* Navigation Buttons */} {!isFirstStep && ( )} ); } const styles = StyleSheet.create({ container: { flex: 1, }, stepContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24, }, contentContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', maxWidth: 400, }, imageContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', marginBottom: 40, minHeight: 200, }, textContainer: { alignItems: 'center', paddingHorizontal: 20, marginBottom: 40, }, title: { textAlign: 'center', marginBottom: 16, paddingHorizontal: 20, }, description: { textAlign: 'center', lineHeight: 24, paddingHorizontal: 20, }, customContent: { alignItems: 'center', paddingHorizontal: 20, marginTop: 20, }, progressContainer: { flexDirection: 'row', justifyContent: 'center', alignItems: 'center', paddingVertical: 20, }, progressDot: { width: 8, height: 8, borderRadius: 4, marginHorizontal: 4, }, skipContainer: { position: 'absolute', top: 60, right: 10, zIndex: 1, }, buttonContainer: { width: '100%', height: 90, flexDirection: 'row', paddingHorizontal: 24, paddingBottom: 40, gap: 12, }, fullWidthButton: { flex: 1, }, }); // Onboarding Hook for managing state export function useOnboarding() { const [hasCompletedOnboarding, setHasCompletedOnboarding] = useState(false); const [currentOnboardingStep, setCurrentOnboardingStep] = useState(0); const completeOnboarding = async () => { try { // In a real app, you'd save this to AsyncStorage or similar setHasCompletedOnboarding(true); console.log('Onboarding completed and saved'); } catch (error) { console.error('Failed to save onboarding completion:', error); } }; const resetOnboarding = () => { setHasCompletedOnboarding(false); setCurrentOnboardingStep(0); }; const skipOnboarding = async () => { await completeOnboarding(); }; return { hasCompletedOnboarding, currentOnboardingStep, setCurrentOnboardingStep, completeOnboarding, resetOnboarding, skipOnboarding, }; } ``` **3.** Update the import paths to match your project setup. **4.** Make sure to configure react-native-gesture-handler and react-native-reanimated in your project following their installation guides. ## Usage ```tsx import { Onboarding, OnboardingStep } from '@/components/ui/onboarding'; ``` ```tsx const steps: OnboardingStep[] = [ { id: '1', title: 'Welcome', description: 'Get started with our amazing app', icon: , }, { id: '2', title: 'Explore Features', description: 'Discover all the powerful features we offer', icon: , }, { id: '3', title: 'Get Started', description: "You are all set! Let's begin your journey", icon: , }, ]; console.log('Onboarding completed')} onSkip={() => console.log('Onboarding skipped')} />; ``` ## Examples #### Default **Example:** A basic onboarding flow with multiple steps ```tsx // components/demo/onboarding/onboarding-demo.tsx import { Onboarding, useOnboarding } from '@/components/ui/onboarding'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; export const OnboardingPresets = { welcome: [ { id: 'welcome', title: 'Welcome to Our App', description: 'Discover amazing features and get started with your journey.', icon: 👋, }, { id: 'features', title: 'Powerful Features', description: 'Experience cutting-edge functionality designed to make your life easier.', icon: , }, { id: 'personalize', title: 'Personalize Your Experience', description: 'Customize the app to match your preferences and workflow.', icon: 🎨, }, { id: 'ready', title: "You're All Set!", description: "Everything is ready. Let's start exploring what you can achieve.", icon: 🚀, }, ], features: [ { id: 'organize', title: 'Stay Organized', description: 'Keep all your important information in one secure place.', icon: 📋, }, { id: 'collaborate', title: 'Collaborate Seamlessly', description: 'Work together with your team in real-time, anywhere.', icon: 🤝, }, { id: 'automate', title: 'Automate Your Workflow', description: 'Set up smart automations to save time and reduce errors.', icon: 🤖, }, ], security: [ { id: 'secure', title: 'Your Data is Secure', description: 'We use end-to-end encryption to keep your information safe.', icon: 🔒, }, { id: 'privacy', title: 'Privacy First', description: 'We never share your personal data with third parties.', icon: 🛡️, }, { id: 'control', title: "You're in Control", description: 'Manage your privacy settings and data preferences anytime.', icon: ⚙️, }, ], }; export function OnboardingDemo() { const { hasCompletedOnboarding, completeOnboarding, skipOnboarding } = useOnboarding(); if (hasCompletedOnboarding) { return ( Welcome Back! You've already completed the onboarding. ); } return ( ); } ``` #### With Images **Example:** Onboarding flow with custom images for each step ```tsx // components/demo/onboarding/onboarding-images.tsx import { Image } from '@/components/ui/image'; import { Onboarding, OnboardingStep } from '@/components/ui/onboarding'; import React from 'react'; const WelcomeImage = () => ( ); const FeaturesImage = () => ( ); const StartImage = () => ( ); export function OnboardingImages() { const steps: OnboardingStep[] = [ { id: '1', title: 'Welcome to the Team', description: 'Join thousands of users who have already discovered the power of our platform.', image: , }, { id: '2', title: 'Powerful Features', description: 'Access advanced tools and features that will help you achieve your goals faster.', image: , }, { id: '3', title: 'Ready to Launch', description: "Everything is set up and ready. Let's start building something amazing together!", image: , }, ]; return ( console.log('Onboarding with images completed!')} onSkip={() => console.log('Onboarding with images skipped!')} primaryButtonText="Let's Go" nextButtonText='Continue' /> ); } ``` #### Custom Styling **Example:** Onboarding with custom colors and styling ```tsx // components/demo/onboarding/onboarding-styled.tsx import { Onboarding, OnboardingStep } from '@/components/ui/onboarding'; import { Feather } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; import React from 'react'; const GradientIcon = ({ iconName, colors, }: { iconName: string; colors: [string, string]; }) => ( ); export function OnboardingStyled() { const steps: OnboardingStep[] = [ { id: '1', title: 'Secure & Private', description: 'Your data is protected with end-to-end encryption. We prioritize your privacy above all else.', icon: , backgroundColor: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', }, { id: '2', title: 'Lightning Fast', description: 'Experience blazing fast performance with our optimized infrastructure and smart caching.', icon: , backgroundColor: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)', }, { id: '3', title: 'Always Connected', description: 'Stay connected with real-time sync across all your devices. Never miss an important update.', icon: , backgroundColor: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)', }, ]; return ( console.log('Styled onboarding completed!')} onSkip={() => console.log('Styled onboarding skipped!')} primaryButtonText='Start Now' nextButtonText='Next Step' skipButtonText='Skip Intro' style={{ backgroundColor: '#1a1a2e' }} /> ); } ``` #### No Skip Button **Example:** Onboarding flow without skip functionality ```tsx // components/demo/onboarding/onboarding-no-skip.tsx import { Onboarding, OnboardingStep } from '@/components/ui/onboarding'; import { Feather } from '@expo/vector-icons'; import React from 'react'; import { View } from 'react-native'; const InfoIcon = ({ name, color }: { name: string; color: string }) => ( ); export function OnboardingNoSkip() { const steps: OnboardingStep[] = [ { id: '1', title: 'Setup Your Profile', description: "Let's start by setting up your profile. This helps us personalize your experience.", icon: , }, { id: '2', title: 'Choose Preferences', description: 'Select your preferences to customize the app according to your needs and workflow.', icon: , }, { id: '3', title: 'Enable Notifications', description: 'Stay updated with important notifications. You can always change these settings later.', icon: , }, { id: '4', title: 'All Set!', description: "Congratulations! Your account is now ready. Let's dive in and explore the features.", icon: , }, ]; return ( console.log('Required onboarding completed!')} showSkip={false} primaryButtonText='Complete Setup' nextButtonText='Continue' backButtonText='Previous' /> ); } ``` #### Swipe Disabled **Example:** Onboarding with swipe gestures disabled ```tsx // components/demo/onboarding/onboarding-no-swipe.tsx import { Onboarding, OnboardingStep } from '@/components/ui/onboarding'; import { Feather } from '@expo/vector-icons'; import React from 'react'; import { View } from 'react-native'; const StepIcon = ({ name, bgColor, iconColor, }: { name: string; bgColor: string; iconColor: string; }) => ( ); export function OnboardingNoSwipe() { const steps: OnboardingStep[] = [ { id: '1', title: 'Tutorial Mode', description: 'Follow along with our step-by-step tutorial. Use the buttons below to navigate at your own pace.', icon: , }, { id: '2', title: 'Learn the Basics', description: 'Master the fundamental features that will help you get the most out of our platform.', icon: , }, { id: '3', title: 'Practice Makes Perfect', description: 'Try out the features yourself in a safe environment before working with real data.', icon: , }, ]; return ( console.log('Tutorial completed!')} onSkip={() => console.log('Tutorial skipped!')} swipeEnabled={false} showProgress={true} primaryButtonText='Start Using App' nextButtonText='Next Lesson' skipButtonText='Skip Tutorial' /> ); } ``` #### Custom Buttons **Example:** Onboarding with custom button text ```tsx // components/demo/onboarding/onboarding-custom-buttons.tsx import { Onboarding, OnboardingStep } from '@/components/ui/onboarding'; import { Heart, Rocket, Target } from 'lucide-react-native'; import React from 'react'; export function OnboardingCustomButtons() { const steps: OnboardingStep[] = [ { id: '1', title: '🎉 Welcome Aboard!', description: "We're thrilled to have you join our community of innovators and creators.", icon: , }, { id: '2', title: '🚀 Boost Your Productivity', description: 'Discover powerful tools that will transform the way you work and collaborate.', icon: , }, { id: '3', title: '🎯 Achieve Your Goals', description: 'Set ambitious targets and track your progress with our advanced analytics.', icon: , }, ]; return ( console.log('Custom buttons onboarding completed!')} onSkip={() => console.log('Custom buttons onboarding skipped!')} primaryButtonText='🚀 Launch App' nextButtonText='👉 Continue' backButtonText='👈 Back' skipButtonText='⏭️ Skip for Now' /> ); } ``` #### With Hook **Example:** Using the onboarding hook for state management ```tsx // components/demo/onboarding/onboarding-hook.tsx import { Button } from '@/components/ui/button'; import { Onboarding, OnboardingStep, useOnboarding, } from '@/components/ui/onboarding'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; function MainApp() { const { resetOnboarding } = useOnboarding(); return ( {/* {} */} Welcome to the App! You've successfully completed the onboarding process. You can restart it anytime using the button below. ); } function OnboardingFlow() { const { completeOnboarding, skipOnboarding } = useOnboarding(); const steps: OnboardingStep[] = [ { id: '1', title: 'Hook-based State', description: 'This onboarding uses the useOnboarding hook to manage state across your entire app.', // icon: '🪝', }, { id: '2', title: 'Persistent State', description: 'The hook remembers your progress and can be used to control onboarding flow throughout your app.', // icon: '📱', }, { id: '3', title: 'Easy Integration', description: 'Integrate onboarding state with your existing app navigation and user management.', // icon: '🚀', }, ]; return ( ); } export function OnboardingHook() { const { hasCompletedOnboarding } = useOnboarding(); return hasCompletedOnboarding ? : ; } ``` ## API Reference ### Onboarding The main onboarding component that manages the multi-step flow. | Prop | Type | Default | Description | | ------------------- | ------------------ | --------------- | --------------------------------------------- | | `steps` | `OnboardingStep[]` | - | Array of onboarding steps to display. | | `onComplete` | `() => void` | - | Callback when onboarding is completed. | | `onSkip` | `() => void` | - | Callback when onboarding is skipped. | | `showSkip` | `boolean` | `true` | Whether to show the skip button. | | `showProgress` | `boolean` | `true` | Whether to show progress dots. | | `swipeEnabled` | `boolean` | `true` | Whether to enable swipe gestures. | | `primaryButtonText` | `string` | `"Get Started"` | Text for the primary button on the last step. | | `skipButtonText` | `string` | `"Skip"` | Text for the skip button. | | `nextButtonText` | `string` | `"Next"` | Text for the next button. | | `backButtonText` | `string` | `"Back"` | Text for the back button. | | `style` | `ViewStyle` | - | Additional styles for the container. | | `children` | `ReactNode` | - | Custom content to render in each step. | ### OnboardingStep Configuration object for each step in the onboarding flow. | Prop | Type | Default | Description | | ----------------- | ----------- | ------- | ------------------------------------------- | | `id` | `string` | - | Unique identifier for the step. | | `title` | `string` | - | Title text for the step. | | `description` | `string` | - | Description text for the step. | | `image` | `ReactNode` | - | Custom image component for the step. | | `icon` | `ReactNode` | - | Icon component (used if no image provided). | | `backgroundColor` | `string` | - | Custom background color for the step. | ### useOnboarding Hook A hook for managing onboarding state in your application. ```tsx const { hasCompletedOnboarding, currentOnboardingStep, setCurrentOnboardingStep, completeOnboarding, resetOnboarding, skipOnboarding, } = useOnboarding(); ``` #### Returns | Property | Type | Description | | -------------------------- | ------------------------ | ------------------------------------------ | | `hasCompletedOnboarding` | `boolean` | Whether the user has completed onboarding. | | `currentOnboardingStep` | `number` | Current step index in the onboarding flow. | | `setCurrentOnboardingStep` | `(step: number) => void` | Function to set the current step. | | `completeOnboarding` | `() => Promise` | Function to mark onboarding as completed. | | `resetOnboarding` | `() => void` | Function to reset onboarding state. | | `skipOnboarding` | `() => Promise` | Function to skip and complete onboarding. | ## Features - **Smooth Animations**: Built with react-native-reanimated for fluid transitions - **Gesture Support**: Swipe left/right to navigate between steps - **Customizable**: Extensive customization options for styling and behavior - **Progress Indicators**: Visual progress dots to show current step - **Flexible Content**: Support for images, icons, and custom content - **State Management**: Built-in hook for managing onboarding state - **Accessibility**: Built with accessibility features in mind - **TypeScript**: Full TypeScript support with proper type definitions ## Accessibility The Onboarding component is built with accessibility in mind: - Announces each step change via `AccessibilityInfo.announceForAccessibility` (". Step X of Y.") - The decorative progress dots are hidden from the accessibility tree <!-- ---------------------------------------------------------------------- --> # ParallaxScrollView > A scroll view with parallax header effect that transforms as the user scrolls. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/parallax-scrollview - Markdown: https://ui.ahmedbna.com/docs/components/parallax-scrollview.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/parallax-scrollview.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/parallax-scrollview.json - Install: `npx bna-ui add parallax-scrollview` - npm dependencies: `expo-router`, `react-native-reanimated`, `react-native-worklets` - Registry dependencies: `useBottomTabOverflow`, `mode-provider`, `useColorScheme`, `colors`, `useColor`, `view` - Preview recording: https://demo.ahmedbna.com/0205-parallax-scrollview-demo.MP4 --- **Example:** A basic parallax scroll view with header image ```tsx // components/demo/parallax-scrollview/parallax-scrollview-demo.tsx import { ParallaxScrollView } from '@/components/ui/parallax-scrollview'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { Image } from 'expo-image'; import React from 'react'; export function ParallaxScrollViewDemo() { return ( <ParallaxScrollView headerHeight={460} headerImage={ <Image source={{ uri: 'https://images.unsplash.com/photo-1637858868799-7f26a0640eb6?q=80&w=2960&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }} style={{ width: '100%', height: '100%' }} contentFit='cover' /> } > <View style={{ gap: 16 }}> <Text variant='heading'>Parallax Scroll View</Text> <Text> This is a basic example of a parallax scroll view. The header image moves at a different speed than the content as you scroll, creating a beautiful parallax effect. </Text> <Text> Scroll up and down to see the parallax animation in action. The header will transform and scale based on your scroll position. </Text> <Text> You can also try pulling down (over-scrolling) to see the header scale up beyond its normal size. </Text> </View> </ParallaxScrollView> ); } ``` ## Installation ### CLI ```bash npx bna-ui add parallax-scrollview ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install react-native-reanimated ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/parallax-scrollview.tsx import { View } from '@/components/ui/view'; import { useBottomTabOverflow } from '@/hooks/useBottomTabOverflow'; import { useColor } from '@/hooks/useColor'; import type { PropsWithChildren, ReactElement } from 'react'; import Animated, { interpolate, useAnimatedRef, useAnimatedStyle, useReducedMotion, useScrollViewOffset, } from 'react-native-reanimated'; type Props = PropsWithChildren<{ headerHeight?: number; headerImage: ReactElement; }>; export function ParallaxScrollView({ children, headerHeight = 250, headerImage, }: Props) { const backgroundColor = useColor('background'); const scrollRef = useAnimatedRef<Animated.ScrollView>(); const scrollOffset = useScrollViewOffset(scrollRef); const bottom = useBottomTabOverflow(); const reduceMotion = useReducedMotion(); const headerAnimatedStyle = useAnimatedStyle(() => { if (reduceMotion) { return { transform: [{ translateY: 0 }, { scale: 1 }] }; } return { transform: [ { translateY: interpolate( scrollOffset.value, [-headerHeight, 0, headerHeight], [-headerHeight / 2, 0, headerHeight * 0.75] ), }, { scale: interpolate( scrollOffset.value, [-headerHeight, 0, headerHeight], [2, 1, 1] ), }, ], }; }); return ( <View style={{ flex: 1, }} > <Animated.ScrollView ref={scrollRef} scrollEventThrottle={16} scrollIndicatorInsets={{ bottom }} contentContainerStyle={{ paddingBottom: bottom }} > <Animated.View style={[ { backgroundColor, overflow: 'hidden', height: headerHeight, }, headerAnimatedStyle, ]} > {headerImage} </Animated.View> <View style={{ flex: 1, padding: 32, gap: 16, overflow: 'hidden', backgroundColor, }} > {children} </View> </Animated.ScrollView> </View> ); } ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { ParallaxScrollView } from '@/components/ui/parallax-scrollview'; import { Image } from 'expo-image'; ``` ```tsx <ParallaxScrollView headerHeight={300} headerImage={ <Image source={{ uri: 'https://example.com/header-image.jpg' }} style={{ width: '100%', height: '100%' }} contentFit='cover' /> } > <Text>Your scrollable content goes here...</Text> </ParallaxScrollView> ``` ## Examples #### Default **Example:** A basic parallax scroll view with header image ```tsx // components/demo/parallax-scrollview/parallax-scrollview-demo.tsx import { ParallaxScrollView } from '@/components/ui/parallax-scrollview'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { Image } from 'expo-image'; import React from 'react'; export function ParallaxScrollViewDemo() { return ( <ParallaxScrollView headerHeight={460} headerImage={ <Image source={{ uri: 'https://images.unsplash.com/photo-1637858868799-7f26a0640eb6?q=80&w=2960&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }} style={{ width: '100%', height: '100%' }} contentFit='cover' /> } > <View style={{ gap: 16 }}> <Text variant='heading'>Parallax Scroll View</Text> <Text> This is a basic example of a parallax scroll view. The header image moves at a different speed than the content as you scroll, creating a beautiful parallax effect. </Text> <Text> Scroll up and down to see the parallax animation in action. The header will transform and scale based on your scroll position. </Text> <Text> You can also try pulling down (over-scrolling) to see the header scale up beyond its normal size. </Text> </View> </ParallaxScrollView> ); } ``` #### Custom Header Height **Example:** Parallax scroll view with custom header height ```tsx // components/demo/parallax-scrollview/parallax-scrollview-custom-height.tsx import { ParallaxScrollView } from '@/components/ui/parallax-scrollview'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { Image } from 'expo-image'; import React from 'react'; export function ParallaxScrollViewCustomHeight() { return ( <ParallaxScrollView headerHeight={500} headerImage={ <Image source={{ uri: 'https://images.unsplash.com/photo-1644190022446-04b99df7259a?q=80&w=2012&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }} style={{ width: '100%', height: '100%' }} contentFit='cover' /> } > <View style={{ gap: 16 }}> <Text variant='heading'>Custom Header Height</Text> <Text> This example demonstrates a taller header (500px) that provides more visual impact and space for the parallax effect. </Text> <Text> Larger headers work great for hero sections, profile pages, or any screen where you want to make a strong visual impression. </Text> <Text> The parallax animation remains smooth regardless of the header size, automatically adjusting the transformation values based on the specified height. </Text> <Text> Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. </Text> </View> </ParallaxScrollView> ); } ``` #### Gradient Header **Example:** Parallax scroll view with gradient overlay header ```tsx // components/demo/parallax-scrollview/parallax-scrollview-gradient.tsx import { ParallaxScrollView } from '@/components/ui/parallax-scrollview'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { Image } from 'expo-image'; import { LinearGradient } from 'expo-linear-gradient'; import React from 'react'; export function ParallaxScrollViewGradient() { return ( <ParallaxScrollView headerHeight={300} headerImage={ <View style={{ position: 'relative', width: '100%', height: '100%' }}> <Image source={{ uri: 'https://images.unsplash.com/photo-1575737698350-52e966f924d4?q=80&w=2070&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }} style={{ width: '100%', height: '100%' }} contentFit='cover' /> <LinearGradient colors={['transparent', 'black']} style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: '50%', }} /> <View style={{ position: 'absolute', bottom: 20, left: 20, right: 20, }} > <Text style={{ color: 'white', fontSize: 24, fontWeight: 'bold', textShadowColor: 'rgba(0,0,0,0.5)', textShadowOffset: { width: 1, height: 1 }, textShadowRadius: 2, }} > Scenic Mountain View </Text> </View> </View> } > <View style={{ gap: 16 }}> <Text variant='heading'>Gradient Overlay Header</Text> <Text> This example shows how to add a gradient overlay to your header image, which is perfect for ensuring text readability over images. </Text> <Text> The gradient creates a smooth transition from the image to a darker overlay at the bottom, where you can place text or other UI elements. </Text> <Text> This technique is commonly used in hero sections, article headers, and profile screens where you need to overlay content on images. </Text> <Text> The parallax effect works seamlessly with gradient overlays and maintains smooth performance even with multiple layers. </Text> </View> </ParallaxScrollView> ); } ``` #### Profile Screen **Example:** Complete profile screen using parallax scroll view ```tsx // components/demo/parallax-scrollview/parallax-scrollview-profile.tsx import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; import { ParallaxScrollView } from '@/components/ui/parallax-scrollview'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { Image } from 'expo-image'; import { LinearGradient } from 'expo-linear-gradient'; import React from 'react'; export function ParallaxScrollViewProfile() { return ( <ParallaxScrollView headerHeight={320} headerImage={ <View style={{ position: 'relative', width: '100%', height: '100%' }}> <Image source={{ uri: 'https://images.unsplash.com/photo-1637858868799-7f26a0640eb6?q=80&w=2960&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', }} style={{ width: '100%', height: '100%' }} contentFit='cover' /> <LinearGradient colors={['transparent', 'black']} style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: '80%', }} /> <View style={{ position: 'absolute', bottom: 20, left: 0, right: 0, alignItems: 'center', gap: 12, }} > <Avatar size={90}> <AvatarImage source={{ uri: 'https://avatars.githubusercontent.com/u/99088394?v=4', }} /> <AvatarFallback>JD</AvatarFallback> </Avatar> <View style={{ alignItems: 'center' }}> <Text style={{ color: 'white', fontSize: 24, fontWeight: 'bold', textShadowColor: 'rgba(0,0,0,0.5)', textShadowOffset: { width: 1, height: 1 }, textShadowRadius: 2, }} > John Doe </Text> <Text style={{ color: '#e0e0e0', fontSize: 16, textShadowColor: 'rgba(0,0,0,0.5)', textShadowOffset: { width: 1, height: 1 }, textShadowRadius: 2, }} > Software Engineer </Text> </View> </View> </View> } > <View style={{ gap: 20 }}> <View style={{ gap: 8 }}> <Text variant='title'>About</Text> <Text> Passionate software engineer with 5+ years of experience in mobile and web development. Specialized in React Native, TypeScript, and modern UI frameworks. </Text> </View> <View style={{ gap: 8 }}> <Text variant='title'>Skills</Text> <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8, }} > {[ 'React Native', 'TypeScript', 'Node.js', 'GraphQL', 'MongoDB', ].map((skill) => ( <Badge variant='success' key={skill}> {skill} </Badge> ))} </View> </View> <View style={{ gap: 8 }}> <Text variant='title'>Experience</Text> <View style={{ gap: 12 }}> <View> <Text variant='subtitle'>Senior Mobile Developer</Text> <Text variant='caption'>Tech Corp • 2022 - Present</Text> <Text style={{ marginTop: 4 }}> Leading mobile development team and architecting scalable React Native applications. </Text> </View> <View> <Text variant='subtitle'>Frontend Developer</Text> <Text variant='caption'>StartupXYZ • 2020 - 2022</Text> <Text style={{ marginTop: 4 }}> Built responsive web applications using React and modern frontend technologies. </Text> </View> </View> </View> <View style={{ gap: 8 }}> <Text variant='title'>Contact</Text> <Text>📧 john.doe@example.com</Text> <Text>🌐 johndoe.dev</Text> <Text>📱 LinkedIn: @johndoe</Text> </View> </View> </ParallaxScrollView> ); } ``` #### Article View **Example:** Article layout with parallax hero image ```tsx // components/demo/parallax-scrollview/parallax-scrollview-article.tsx import { ParallaxScrollView } from '@/components/ui/parallax-scrollview'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { Image } from 'expo-image'; import { LinearGradient } from 'expo-linear-gradient'; import React from 'react'; export function ParallaxScrollViewArticle() { return ( <ParallaxScrollView headerHeight={280} headerImage={ <View style={{ position: 'relative', width: '100%', height: '100%' }}> <Image source={{ uri: 'https://images.unsplash.com/photo-1486312338219-ce68d2c6f44d?w=800&h=600&fit=crop', }} style={{ width: '100%', height: '100%' }} contentFit='cover' /> <LinearGradient colors={['transparent', 'rgba(0,0,0,0.7)']} style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: '60%', }} /> <View style={{ position: 'absolute', bottom: 20, left: 20, right: 20, }} > <View style={{ backgroundColor: 'green', paddingHorizontal: 8, paddingVertical: 4, borderRadius: 4, alignSelf: 'flex-start', marginBottom: 8, }} > <Text style={{ color: 'white', fontSize: 12, fontWeight: '600' }}> TECHNOLOGY </Text> </View> <Text style={{ color: 'white', fontSize: 24, fontWeight: 'bold', lineHeight: 32, textShadowColor: 'rgba(0,0,0,0.5)', textShadowOffset: { width: 1, height: 1 }, textShadowRadius: 2, }} > The Future of Mobile Development </Text> <Text style={{ color: '#e0e0e0', fontSize: 14, marginTop: 8, textShadowColor: 'rgba(0,0,0,0.5)', textShadowOffset: { width: 1, height: 1 }, textShadowRadius: 2, }} > Published on March 15, 2024 • 8 min read </Text> </View> </View> } > <View style={{ gap: 16 }}> <Text variant='caption' style={{ fontSize: 18, lineHeight: 28 }}> Mobile development has evolved dramatically over the past decade, with new frameworks, tools, and paradigms emerging to meet the ever-growing demands of users and businesses alike. </Text> <Text variant='caption'> React Native has established itself as a leading cross-platform solution, enabling developers to write once and deploy everywhere. The framework's component-based architecture and hot reloading capabilities have revolutionized the development experience. </Text> <View style={{ gap: 8 }}> <Text variant='title'>Key Trends in 2024</Text> <Text variant='caption'> • AI-powered development tools and code generation </Text> <Text variant='caption'> • Enhanced performance optimization techniques </Text> <Text variant='caption'> • Better cross-platform native module integration </Text> <Text variant='caption'> • Improved debugging and testing frameworks </Text> </View> <View style={{ padding: 16, borderRadius: 8, borderLeftWidth: 4, borderLeftColor: '#3b82f6', }} > <Text style={{ fontSize: 16, lineHeight: 24, fontStyle: 'italic' }}> "The best mobile apps are those that feel native to each platform while maintaining a consistent user experience across devices." </Text> </View> <Text variant='caption'> Performance optimization remains a critical consideration. Modern apps need to handle complex animations, large datasets, and real-time updates while maintaining smooth 60fps interactions. </Text> <View style={{ gap: 8 }}> <Text variant='title'>Looking Ahead</Text> <Text variant='caption'> The future of mobile development is bright, with emerging technologies like AR/VR integration, improved offline capabilities, and seamless cloud integration opening new possibilities for developers and users alike. </Text> </View> <View style={{ borderTopWidth: 0.5, borderTopColor: '#e5e7eb', paddingTop: 16, marginTop: 16, }} > <View style={{ flexDirection: 'row', alignItems: 'center', gap: 12, }} > <View style={{ width: 40, height: 40, borderRadius: 20, backgroundColor: '#3b82f6', alignItems: 'center', justifyContent: 'center', }} > <Text style={{ color: 'white', fontWeight: 'bold' }}>JD</Text> </View> <View> <Text style={{ fontWeight: '600' }}>John Developer</Text> <Text style={{ color: '#6b7280', fontSize: 14 }}> Senior Mobile Engineer </Text> </View> </View> </View> </View> </ParallaxScrollView> ); } ``` #### Product Gallery **Example:** Product detail screen with parallax image gallery ```tsx // components/demo/parallax-scrollview/parallax-scrollview-product.tsx import { Button } from '@/components/ui/button'; import { ParallaxScrollView } from '@/components/ui/parallax-scrollview'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { Image } from 'expo-image'; import React from 'react'; export function ParallaxScrollViewProduct() { return ( <ParallaxScrollView headerHeight={350} headerImage={ <Image source={{ uri: 'https://images.unsplash.com/photo-1542291026-7eec264c27ff?w=800&h=600&fit=crop', }} style={{ width: '100%', height: '100%' }} contentFit='cover' /> } > <View style={{ gap: 20 }}> <View style={{ gap: 8 }}> <View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start', }} > <View style={{ flex: 1 }}> <Text variant='heading'>Running Shoes</Text> <Text variant='caption' style={{ marginTop: 4 }}> Nike Air Zoom Series </Text> </View> <Text style={{ fontSize: 24, fontWeight: 'bold', color: '#dc2626', }} > $159.99 </Text> </View> <View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}> <View style={{ flexDirection: 'row', gap: 2 }}> {[1, 2, 3, 4, 5].map((star) => ( <Text key={star} style={{ color: '#fbbf24', fontSize: 16 }}> ★ </Text> ))} </View> <Text variant='caption'>4.8 (2.1k reviews)</Text> </View> </View> <View style={{ gap: 8 }}> <Text variant='title'>Description</Text> <Text variant='caption' style={{ fontSize: 16, lineHeight: 24 }}> Experience ultimate comfort and performance with these premium running shoes. Featuring advanced cushioning technology and breathable mesh construction for all-day comfort. </Text> </View> <View style={{ gap: 12 }}> <Text variant='title'>Available Sizes</Text> <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8, }} > {['7', '7.5', '8', '8.5', '9', '9.5', '10', '10.5', '11'].map( (size) => ( <View key={size} style={{ borderWidth: 1, borderColor: '#d1d5db', paddingHorizontal: 16, paddingVertical: 8, borderRadius: 8, minWidth: 50, alignItems: 'center', }} > <Text style={{ fontWeight: '500' }}>US {size}</Text> </View> ) )} </View> </View> <View style={{ gap: 12 }}> <Text variant='title'>Color Options</Text> <View style={{ flexDirection: 'row', gap: 12 }}> <View style={{ width: 40, height: 40, borderRadius: 20, backgroundColor: '#1f2937', borderWidth: 2, borderColor: '#3b82f6', }} /> <View style={{ width: 40, height: 40, borderRadius: 20, backgroundColor: '#ffffff', borderWidth: 1, borderColor: '#d1d5db', }} /> <View style={{ width: 40, height: 40, borderRadius: 20, backgroundColor: '#dc2626', }} /> <View style={{ width: 40, height: 40, borderRadius: 20, backgroundColor: '#2563eb', }} /> </View> </View> <View style={{ gap: 8 }}> <Text variant='title'>Features</Text> <View style={{ gap: 8 }}> <View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }} > <Text style={{ color: '#10b981' }}>✓</Text> <Text variant='caption'>Lightweight design for all-day wear</Text> </View> <View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }} > <Text style={{ color: '#10b981' }}>✓</Text> <Text variant='caption'>Enhanced arch support</Text> </View> </View> </View> <View style={{ borderRadius: 8, gap: 12, }} > <Text variant='title'>Shipping & Returns</Text> <Text style={{ fontSize: 14 }}> • Free shipping on orders over $100 </Text> <Text style={{ fontSize: 14 }}>• 30-day return policy</Text> <Text style={{ fontSize: 14 }}>• 1-year manufacturer warranty</Text> </View> <View style={{ gap: 12, marginTop: 8 }}> <Button variant='success'>Add to Cart</Button> <Button variant='destructive'>Add to Wishlist</Button> </View> </View> </ParallaxScrollView> ); } ``` ## API Reference ### ParallaxScrollView The main component that provides parallax scrolling functionality. | Prop | Type | Default | Description | | -------------- | -------------- | ------- | ----------------------------------------------------- | | `children` | `ReactNode` | - | The scrollable content below the header. | | `headerHeight` | `number` | `250` | The height of the parallax header in pixels. | | `headerImage` | `ReactElement` | - | The header content (image, video, or custom element). | ### Animation Behavior The ParallaxScrollView component provides smooth parallax animations: - **Transform**: Header moves at different speeds during scroll - **Scale**: Header scales up when pulled down (over-scroll) - **Performance**: Uses native driver for 60fps animations - **Responsive**: Adapts to different screen sizes and orientations ### Scroll Effects - **Pull Down**: Header scales up to 2x when over-scrolling - **Scroll Up**: Header translates upward at 0.5x scroll speed - **Scale Down**: Header maintains aspect ratio during transformations ## Accessibility The ParallaxScrollView component follows accessibility best practices: - Gates the header parallax transform behind `useReducedMotion()`, so the header stays static for users with reduced motion enabled ## Performance Tips - Use optimized images with appropriate resolutions - Consider lazy loading for heavy content - Use `scrollEventThrottle={16}` for smooth animations - Implement proper image caching strategies ## Common Use Cases - Profile screens with hero images - Article headers with featured images - Product detail pages - Landing pages with hero sections - Photo gallery headers - Settings pages with branded headers <!-- ---------------------------------------------------------------------- --> # Picker > A customizable dropdown picker component with search, sections, and multiple selection support. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/picker - Markdown: https://ui.ahmedbna.com/docs/components/picker.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/picker.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/picker.json - Install: `npx bna-ui add picker` - npm dependencies: `expo-haptics`, `lucide-react-native`, `react-native-svg` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `useHaptics`, `globals`, `text`, `view`, `icon`, `scroll-view` - Preview recording: https://demo.ahmedbna.com/0211-picker-demo.MP4 --- **Example:** A basic picker with simple options ```tsx // components/demo/picker/picker-demo.tsx import { Picker } from '@/components/ui/picker'; import React, { useState } from 'react'; export function PickerDemo() { const [value, setValue] = useState<string>(''); const options = [ { label: 'Apple', value: 'apple' }, { label: 'Banana', value: 'banana' }, { label: 'Orange', value: 'orange' }, { label: 'Grape', value: 'grape' }, ]; return ( <Picker options={options} value={value} onValueChange={setValue} placeholder='Select a fruit...' /> ); } ``` ## Installation ### CLI ```bash npx bna-ui add picker ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install lucide-react-native ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/picker.tsx import { Icon } from '@/components/ui/icon'; import { ScrollView } from '@/components/ui/scroll-view'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { useHaptics } from '@/hooks/useHaptics'; import { BORDER_RADIUS, CORNERS, FONT_SIZE, HEIGHT } from '@/theme/globals'; import { ChevronDown, LucideProps } from 'lucide-react-native'; import React, { useMemo, useState } from 'react'; import { Modal, Pressable, TextInput, TextStyle, TouchableOpacity, ViewStyle, } from 'react-native'; export interface PickerOption { label: string; value: string; description?: string; disabled?: boolean; } export interface PickerSection { title?: string; options: PickerOption[]; } interface PickerProps { options?: PickerOption[]; sections?: PickerSection[]; value?: string; placeholder?: string; error?: string; variant?: 'outline' | 'filled' | 'group'; onValueChange?: (value: string) => void; disabled?: boolean; style?: ViewStyle; multiple?: boolean; values?: string[]; onValuesChange?: (values: string[]) => void; // Styling props label?: string; icon?: React.ComponentType<LucideProps>; rightComponent?: React.ReactNode | (() => React.ReactNode); inputStyle?: TextStyle; labelStyle?: TextStyle; errorStyle?: TextStyle; // Modal props modalTitle?: string; searchable?: boolean; searchPlaceholder?: string; haptic?: boolean; } export function Picker({ options = [], sections = [], value, values = [], error, variant = 'filled', placeholder = 'Select an option...', onValueChange, onValuesChange, disabled = false, style, multiple = false, label, icon, rightComponent, inputStyle, labelStyle, errorStyle, modalTitle, searchable = false, searchPlaceholder = 'Search options...', haptic = true, }: PickerProps) { const [isOpen, setIsOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const feedback = useHaptics(haptic); // Move ALL theme color hooks to the top level const borderColor = useColor('border'); const text = useColor('text'); const muted = useColor('mutedForeground'); const cardColor = useColor('card'); const danger = useColor('red'); const accent = useColor('accent'); const primary = useColor('primary'); const primaryForeground = useColor('primaryForeground'); const input = useColor('input'); const mutedBg = useColor('muted'); const textMutedColor = useColor('textMuted'); // Normalize data structure - convert options to sections format const normalizedSections: PickerSection[] = sections.length > 0 ? sections : [{ options }]; // Filter sections based on search query — memoized so typing in an // unrelated part of the screen doesn't re-filter every option on every // render. Depends on `sections`/`options` directly rather than // `normalizedSections`, which is a fresh array every render. const filteredSections = useMemo( () => searchable && searchQuery ? normalizedSections .map((section) => ({ ...section, options: section.options.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase()) ), })) .filter((section) => section.options.length > 0) : normalizedSections, // eslint-disable-next-line react-hooks/exhaustive-deps [searchable, searchQuery, sections, options] ); // Get selected options for display const getSelectedOptions = () => { const allOptions = normalizedSections.flatMap((section) => section.options); if (multiple) { return allOptions.filter((option) => values.includes(option.value)); } else { return allOptions.filter((option) => option.value === value); } }; const selectedOptions = getSelectedOptions(); const handleSelect = (optionValue: string) => { if (multiple) { // Multi-select rows behave like checkboxes, so they get toggle feedback // rather than the one-shot selection tick. const isSelected = values.includes(optionValue); feedback(isSelected ? 'toggle-off' : 'toggle-on'); const newValues = isSelected ? values.filter((v) => v !== optionValue) : [...values, optionValue]; onValuesChange?.(newValues); } else { feedback('selection'); onValueChange?.(optionValue); setIsOpen(false); } }; const handleOpen = () => { if (disabled) return; feedback('impact-light'); setIsOpen(true); }; const getDisplayText = () => { if (selectedOptions.length === 0) return placeholder; if (multiple) { if (selectedOptions.length === 1) { return selectedOptions[0].label; } return `${selectedOptions.length} selected`; } return selectedOptions[0]?.label || placeholder; }; const triggerStyle: ViewStyle = { width: '100%', flexDirection: 'row', alignItems: 'center', paddingHorizontal: variant === 'group' ? 0 : 16, borderWidth: variant === 'group' ? 0 : 1, borderColor: variant === 'outline' ? borderColor : cardColor, borderRadius: CORNERS, backgroundColor: variant === 'filled' ? cardColor : 'transparent', minHeight: variant === 'group' ? 'auto' : HEIGHT, opacity: disabled ? 0.5 : 1, }; const renderOption = ( option: PickerOption, sectionIndex: number, optionIndex: number ) => { const isSelected = multiple ? values.includes(option.value) : value === option.value; return ( <TouchableOpacity key={`${sectionIndex}-${option.value}`} onPress={() => !option.disabled && handleSelect(option.value)} style={{ paddingVertical: 16, paddingHorizontal: 20, borderRadius: CORNERS, backgroundColor: isSelected ? primary : 'transparent', marginVertical: 2, alignItems: 'center', opacity: option.disabled ? 0.3 : 1, }} disabled={option.disabled} accessibilityRole='menuitem' accessibilityState={{ selected: isSelected, disabled: option.disabled }} > <View style={{ width: '100%', alignItems: 'center', }} > <Text style={{ color: isSelected ? primaryForeground : text, fontWeight: isSelected ? '600' : '400', fontSize: FONT_SIZE, textAlign: 'center', }} > {option.label} </Text> {option.description && ( <Text variant='caption' style={{ marginTop: 4, fontSize: 12, color: isSelected ? primaryForeground : textMutedColor, textAlign: 'center', }} > {option.description} </Text> )} </View> </TouchableOpacity> ); }; return ( <> <TouchableOpacity style={[triggerStyle, style]} onPress={handleOpen} disabled={disabled} activeOpacity={0.8} > {/* Icon & Label */} <View style={{ width: label ? 128 : 'auto', flexDirection: 'row', alignItems: 'center', gap: 8, }} pointerEvents='none' > {icon && ( <Icon name={icon} size={16} color={error ? danger : muted} /> )} {label && ( <Text variant='caption' numberOfLines={1} ellipsizeMode='tail' style={[ { color: error ? danger : muted, }, labelStyle, ]} pointerEvents='none' > {label} </Text> )} </View> <View style={{ flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', }} > <Text style={[ { fontSize: FONT_SIZE, color: selectedOptions.length > 0 ? text : disabled ? muted : error ? danger : muted, }, inputStyle, ]} numberOfLines={1} ellipsizeMode='tail' > {getDisplayText()} </Text> {rightComponent ? ( typeof rightComponent === 'function' ? ( rightComponent() ) : ( rightComponent ) ) : ( <ChevronDown size={16} color={error ? danger : muted} style={{ transform: [{ rotate: isOpen ? '180deg' : '0deg' }], }} /> )} </View> </TouchableOpacity> {/* Error message */} {error && ( <Text variant='caption' style={[ { color: danger, marginTop: 4, }, errorStyle, ]} > {error} </Text> )} <Modal visible={isOpen} transparent animationType='fade' onRequestClose={() => setIsOpen(false)} > <Pressable style={{ flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.5)', justifyContent: 'flex-end', alignItems: 'center', }} onPress={() => setIsOpen(false)} > <Pressable style={{ backgroundColor: cardColor, borderTopStartRadius: BORDER_RADIUS, borderTopEndRadius: BORDER_RADIUS, maxHeight: '70%', width: '100%', paddingBottom: 32, overflow: 'hidden', }} onPress={(e) => e.stopPropagation()} > {/* Header */} {(modalTitle || multiple) && ( <View style={{ padding: 16, borderBottomWidth: 1, borderBottomColor: borderColor, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', }} > <Text variant='title'>{modalTitle || 'Select Options'}</Text> {multiple && ( <TouchableOpacity onPress={() => setIsOpen(false)}> <Text style={{ color: primary, fontWeight: '500', }} > Done </Text> </TouchableOpacity> )} </View> )} {/* Search */} {searchable && ( <View style={{ paddingHorizontal: 16, paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: borderColor, }} > <TextInput style={{ height: 36, paddingHorizontal: 12, borderRadius: 8, backgroundColor: input, color: text, fontSize: FONT_SIZE, }} placeholder={searchPlaceholder} placeholderTextColor={muted} value={searchQuery} onChangeText={setSearchQuery} /> </View> )} {/* Options - Updated to match date-picker styling */} <View style={{ height: 300 }}> <ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={{ paddingVertical: 20, paddingHorizontal: 16, }} > {filteredSections.map((section, sectionIndex) => ( <View key={sectionIndex}> {section.title && ( <View style={{ paddingHorizontal: 4, paddingVertical: 12, marginBottom: 8, }} > <Text variant='caption' style={{ fontWeight: '600', color: textMutedColor, fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.5, }} > {section.title} </Text> </View> )} {section.options.map((option, optionIndex) => renderOption(option, sectionIndex, optionIndex) )} </View> ))} {filteredSections.every( (section) => section.options.length === 0 ) && ( <View style={{ paddingHorizontal: 16, paddingVertical: 24, alignItems: 'center', }} > <Text variant='caption' style={{ color: textMutedColor, }} > {searchQuery ? 'No results found' : 'No options available'} </Text> </View> )} </ScrollView> </View> </Pressable> </Pressable> </Modal> </> ); } ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { Picker } from '@/components/ui/picker'; ``` ```tsx <Picker options={[ { label: 'Option 1', value: '1' }, { label: 'Option 2', value: '2' }, { label: 'Option 3', value: '3' }, ]} value={selectedValue} onValueChange={setSelectedValue} placeholder='Select an option...' /> ``` ## Examples #### Default **Example:** A basic picker with simple options ```tsx // components/demo/picker/picker-demo.tsx import { Picker } from '@/components/ui/picker'; import React, { useState } from 'react'; export function PickerDemo() { const [value, setValue] = useState<string>(''); const options = [ { label: 'Apple', value: 'apple' }, { label: 'Banana', value: 'banana' }, { label: 'Orange', value: 'orange' }, { label: 'Grape', value: 'grape' }, ]; return ( <Picker options={options} value={value} onValueChange={setValue} placeholder='Select a fruit...' /> ); } ``` #### With Sections **Example:** Picker with grouped options in sections ```tsx // components/demo/picker/picker-sections.tsx import { Picker } from '@/components/ui/picker'; import React, { useState } from 'react'; export function PickerSections() { const [value, setValue] = useState<string>(''); const sections = [ { title: 'Fruits', options: [ { label: 'Apple', value: 'apple' }, { label: 'Banana', value: 'banana' }, { label: 'Orange', value: 'orange' }, ], }, { title: 'Vegetables', options: [ { label: 'Carrot', value: 'carrot' }, { label: 'Broccoli', value: 'broccoli' }, { label: 'Spinach', value: 'spinach' }, ], }, ]; return ( <Picker sections={sections} value={value} onValueChange={setValue} placeholder='Select an item...' modalTitle='Choose Food' /> ); } ``` #### Multiple Selection **Example:** Picker allowing multiple selections ```tsx // components/demo/picker/picker-multiple.tsx import { Picker } from '@/components/ui/picker'; import React, { useState } from 'react'; export function PickerMultiple() { const [values, setValues] = useState<string[]>([]); const options = [ { label: 'JavaScript', value: 'js' }, { label: 'TypeScript', value: 'ts' }, { label: 'Python', value: 'py' }, { label: 'Java', value: 'java' }, { label: 'C++', value: 'cpp' }, { label: 'Rust', value: 'rust' }, ]; return ( <Picker options={options} values={values} onValuesChange={setValues} placeholder='Select languages...' multiple modalTitle='Programming Languages' /> ); } ``` #### Searchable **Example:** Picker with search functionality ```tsx // components/demo/picker/picker-searchable.tsx import { Picker } from '@/components/ui/picker'; import React, { useState } from 'react'; export function PickerSearchable() { const [value, setValue] = useState<string>(''); const options = [ { label: 'United States', value: 'us' }, { label: 'Canada', value: 'ca' }, { label: 'United Kingdom', value: 'uk' }, { label: 'Germany', value: 'de' }, { label: 'France', value: 'fr' }, { label: 'Japan', value: 'jp' }, { label: 'Australia', value: 'au' }, { label: 'Brazil', value: 'br' }, { label: 'India', value: 'in' }, { label: 'China', value: 'cn' }, ]; return ( <Picker options={options} value={value} onValueChange={setValue} placeholder='Select a country...' searchable searchPlaceholder='Search countries...' modalTitle='Countries' /> ); } ``` #### With Icons and Labels **Example:** Picker with custom styling, icons, and labels ```tsx // components/demo/picker/picker-styled.tsx import { Picker } from '@/components/ui/picker'; import { MapPin, Settings, User } from 'lucide-react-native'; import React, { useState } from 'react'; export function PickerStyled() { const [location, setLocation] = useState<string>(''); const [user, setUser] = useState<string>(''); const [setting, setSetting] = useState<string>(''); const locations = [ { label: 'New York', value: 'ny' }, { label: 'Los Angeles', value: 'la' }, { label: 'Chicago', value: 'chi' }, ]; const users = [ { label: 'John Doe', value: 'john' }, { label: 'Jane Smith', value: 'jane' }, { label: 'Bob Johnson', value: 'bob' }, ]; const settings = [ { label: 'Notifications', value: 'notifications' }, { label: 'Privacy', value: 'privacy' }, { label: 'Account', value: 'account' }, ]; return ( <> <Picker options={locations} value={location} onValueChange={setLocation} placeholder='Select location...' icon={MapPin} label='Location' variant='outline' /> <Picker options={users} value={user} onValueChange={setUser} placeholder='Select user...' icon={User} label='User' variant='filled' style={{ marginTop: 16 }} /> <Picker options={settings} value={setting} onValueChange={setSetting} placeholder='Select setting...' icon={Settings} label='Settings' variant='group' style={{ marginTop: 16 }} /> </> ); } ``` #### Variants **Example:** Different picker variants: outline, filled, and group ```tsx // components/demo/picker/picker-variants.tsx import { Picker } from '@/components/ui/picker'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function PickerVariants() { const [outlineValue, setOutlineValue] = useState<string>(''); const [filledValue, setFilledValue] = useState<string>(''); const [groupValue, setGroupValue] = useState<string>(''); const options = [ { label: 'Small', value: 'sm' }, { label: 'Medium', value: 'md' }, { label: 'Large', value: 'lg' }, { label: 'Extra Large', value: 'xl' }, ]; return ( <View style={{ gap: 20 }}> <View> <Text variant='caption' style={{ marginBottom: 8 }}> Outline Variant </Text> <Picker options={options} value={outlineValue} onValueChange={setOutlineValue} placeholder='Select size...' variant='outline' /> </View> <View> <Text variant='caption' style={{ marginBottom: 8 }}> Filled Variant </Text> <Picker options={options} value={filledValue} onValueChange={setFilledValue} placeholder='Select size...' variant='filled' /> </View> <View> <Text variant='caption' style={{ marginBottom: 8 }}> Group Variant </Text> <Picker options={options} value={groupValue} onValueChange={setGroupValue} placeholder='Select size...' variant='group' /> </View> </View> ); } ``` #### Form Integration **Example:** Picker integrated with form validation and error handling ```tsx // components/demo/picker/picker-form.tsx import { Button } from '@/components/ui/button'; import { Picker } from '@/components/ui/picker'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function PickerForm() { const [category, setCategory] = useState<string>(''); const [priority, setPriority] = useState<string>(''); const [errors, setErrors] = useState<{ category?: string; priority?: string; }>({}); const categories = [ { label: 'Bug Report', value: 'bug' }, { label: 'Feature Request', value: 'feature' }, { label: 'General Inquiry', value: 'general' }, ]; const priorities = [ { label: 'Low', value: 'low' }, { label: 'Medium', value: 'medium' }, { label: 'High', value: 'high' }, { label: 'Critical', value: 'critical' }, ]; const handleSubmit = () => { const newErrors: { category?: string; priority?: string } = {}; if (!category) { newErrors.category = 'Please select a category'; } if (!priority) { newErrors.priority = 'Please select a priority'; } setErrors(newErrors); if (Object.keys(newErrors).length === 0) { // Form is valid console.log('Form submitted:', { category, priority }); } }; return ( <View style={{ gap: 16 }}> <Picker options={categories} value={category} onValueChange={(value) => { setCategory(value); if (errors.category) { setErrors((prev) => ({ ...prev, category: undefined })); } }} placeholder='Select category...' label='Category' error={errors.category} variant='outline' /> <Picker options={priorities} value={priority} onValueChange={(value) => { setPriority(value); if (errors.priority) { setErrors((prev) => ({ ...prev, priority: undefined })); } }} placeholder='Select priority...' label='Priority' error={errors.priority} variant='outline' /> <Button onPress={handleSubmit}>Submit Ticket</Button> </View> ); } ``` #### Advanced Features **Example:** Picker with descriptions, disabled options, and custom modal title ```tsx // components/demo/picker/picker-advanced.tsx import { Picker } from '@/components/ui/picker'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function PickerAdvanced() { const [plan, setPlan] = useState<string>(''); const sections = [ { title: 'Individual Plans', options: [ { label: 'Basic', value: 'basic', description: '$9/month - Perfect for individuals', }, { label: 'Pro', value: 'pro', description: '$19/month - Advanced features included', }, ], }, { title: 'Team Plans', options: [ { label: 'Team', value: 'team', description: '$39/month - Collaboration tools', }, { label: 'Enterprise', value: 'enterprise', description: '$99/month - Full enterprise features', }, { label: 'Custom', value: 'custom', description: 'Contact us for pricing', disabled: true, }, ], }, ]; return ( <View style={{ gap: 16 }}> <Picker sections={sections} value={plan} onValueChange={setPlan} placeholder='Select a plan...' modalTitle='Subscription Plans' searchable searchPlaceholder='Search plans...' variant='outline' /> {plan && ( <View style={{ padding: 12, backgroundColor: '#f0f9ff', borderRadius: 8, borderWidth: 1, borderColor: '#0284c7', }} > <Text style={{ color: '#0284c7', fontWeight: '500' }}> Selected:{' '} { sections.flatMap((s) => s.options).find((o) => o.value === plan) ?.label } </Text> </View> )} </View> ); } ``` ## API Reference ### Picker The main picker component that displays options in a modal. | Prop | Type | Default | Description | | ------------------- | ---------------------------------- | ----------------------- | ---------------------------------------------------------------------------------------- | | `haptic` | `boolean` | `true` | Whether to trigger haptic feedback when the picker opens and when an option is selected. | | `options` | `PickerOption[]` | `[]` | Array of options to display. | | `sections` | `PickerSection[]` | `[]` | Array of sections containing grouped options. | | `value` | `string` | - | Currently selected value (single selection). | | `values` | `string[]` | `[]` | Currently selected values (multiple selection). | | `placeholder` | `string` | `"Select an option..."` | Placeholder text when no option is selected. | | `error` | `string` | - | Error message to display. | | `variant` | `"outline" \| "filled" \| "group"` | `"filled"` | Visual variant of the picker. | | `onValueChange` | `(value: string) => void` | - | Callback when single selection changes. | | `onValuesChange` | `(values: string[]) => void` | - | Callback when multiple selection changes. | | `disabled` | `boolean` | `false` | Whether the picker is disabled. | | `multiple` | `boolean` | `false` | Enable multiple selection mode. | | `label` | `string` | - | Label text to display. | | `icon` | `React.ComponentType<LucideProps>` | - | Icon component to display. | | `rightComponent` | `ReactNode \| (() => ReactNode)` | - | Custom component to display on the right side. | | `modalTitle` | `string` | - | Title for the modal header. | | `searchable` | `boolean` | `false` | Enable search functionality. | | `searchPlaceholder` | `string` | `"Search options..."` | Placeholder for search input. | | `style` | `ViewStyle` | - | Additional styles for the picker container. | | `inputStyle` | `TextStyle` | - | Additional styles for the input text. | | `labelStyle` | `TextStyle` | - | Additional styles for the label text. | | `errorStyle` | `TextStyle` | - | Additional styles for the error text. | ### PickerOption Interface for individual picker options. | Prop | Type | Description | | ------------- | --------- | ------------------------------- | | `label` | `string` | Display text for the option. | | `value` | `string` | Unique value for the option. | | `description` | `string` | Optional description text. | | `disabled` | `boolean` | Whether the option is disabled. | ### PickerSection Interface for grouping options into sections. | Prop | Type | Description | | --------- | ---------------- | ---------------------------- | | `title` | `string` | Optional section title. | | `options` | `PickerOption[]` | Array of options in section. | ## Accessibility - Option rows expose `accessibilityRole="menuitem"` and `accessibilityState={{ selected, disabled }}` so screen readers announce selection state - Uses `Modal` for the options sheet, which traps interaction within it while open - The trigger meets the 44×44 minimum touch target on all variants <!-- ---------------------------------------------------------------------- --> # Popover > A contextual overlay that displays rich content triggered by user interaction. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/popover - Markdown: https://ui.ahmedbna.com/docs/components/popover.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/popover.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/popover.json - Install: `npx bna-ui add popover` - npm dependencies: `expo-haptics`, `lucide-react-native`, `react-native-reanimated`, `react-native-svg`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `useHaptics`, `text`, `view`, `icon`, `spinner`, `button` - Preview recording: https://demo.ahmedbna.com/0219-popover-demo.MP4 --- **Example:** A basic popover with trigger button and content ```tsx // components/demo/popover/popover-demo.tsx import { Button } from '@/components/ui/button'; import { Popover, PopoverBody, PopoverClose, PopoverContent, PopoverFooter, PopoverHeader, PopoverTrigger, } from '@/components/ui/popover'; import { Text } from '@/components/ui/text'; import React from 'react'; export function PopoverDemo() { return ( <Popover> <PopoverTrigger asChild> <Button>Open Popover</Button> </PopoverTrigger> <PopoverContent> <PopoverHeader> <Text variant='title'>Popover Title</Text> </PopoverHeader> <PopoverBody> <Text> This is the popover content. You can put any content here. </Text> </PopoverBody> <PopoverFooter> <PopoverClose> <Button variant='outline' size='sm'> Close </Button> </PopoverClose> </PopoverFooter> </PopoverContent> </Popover> ); } ``` ## Installation ### CLI ```bash npx bna-ui add popover ``` ### Manual **1.** Copy and paste the following code into your project. ```tsx // components/ui/popover.tsx import { Button } from '@/components/ui/button'; import { useColor } from '@/hooks/useColor'; import { BORDER_RADIUS } from '@/theme/globals'; import React, { createContext, ReactNode, useContext, useEffect, useRef, useState, } from 'react'; import { Dimensions, Modal, Pressable, StyleSheet, TouchableOpacity, View, ViewStyle, } from 'react-native'; // Context for sharing state between popover components interface PopoverContextType { isOpen: boolean; setIsOpen: (open: boolean) => void; triggerLayout: { x: number; y: number; width: number; height: number }; setTriggerLayout: (layout: any) => void; } const PopoverContext = createContext<PopoverContextType | undefined>(undefined); const usePopover = () => { const context = useContext(PopoverContext); if (!context) { throw new Error('Popover components must be used within a Popover'); } return context; }; // Main Popover wrapper interface PopoverProps { children: ReactNode; open?: boolean; onOpenChange?: (open: boolean) => void; } export function Popover({ children, open = false, onOpenChange, }: PopoverProps) { const [isOpen, setIsOpenState] = useState(open); const [triggerLayout, setTriggerLayout] = useState({ x: 0, y: 0, width: 0, height: 0, }); // Sync with external open state useEffect(() => { setIsOpenState(open); }, [open]); const setIsOpen = (newOpen: boolean) => { setIsOpenState(newOpen); onOpenChange?.(newOpen); }; return ( <PopoverContext.Provider value={{ isOpen, setIsOpen, triggerLayout, setTriggerLayout, }} > {children} </PopoverContext.Provider> ); } // Popover Trigger interface PopoverTriggerProps { children: ReactNode; asChild?: boolean; style?: ViewStyle; } export function PopoverTrigger({ children, asChild = false, style, }: PopoverTriggerProps) { const { setIsOpen, setTriggerLayout, isOpen } = usePopover(); const triggerRef = useRef<React.ComponentRef<typeof TouchableOpacity>>(null); const measureTrigger = () => { if (triggerRef.current) { triggerRef.current.measure( ( x: number, y: number, width: number, height: number, pageX: number, pageY: number ) => { setTriggerLayout({ x: pageX, y: pageY, width, height }); } ); } }; const handlePress = () => { measureTrigger(); setIsOpen(!isOpen); }; if (asChild && React.isValidElement(children)) { // Clone the child and add onPress handler return React.cloneElement(children, { ref: triggerRef, onPress: handlePress, accessibilityRole: 'button', accessibilityState: { expanded: isOpen }, style: [(children.props as any).style, style], } as any); } return ( <Button ref={triggerRef} style={style} onPress={handlePress} accessibilityRole='button' accessibilityState={{ expanded: isOpen }} > {children} </Button> ); } // Popover Content interface PopoverContentProps { children: ReactNode; align?: 'start' | 'center' | 'end'; side?: 'top' | 'right' | 'bottom' | 'left'; sideOffset?: number; alignOffset?: number; style?: ViewStyle; maxWidth?: number; maxHeight?: number; } export function PopoverContent({ children, align = 'center', side = 'bottom', sideOffset = 8, alignOffset = 0, style, maxWidth = 300, maxHeight = 400, }: PopoverContentProps) { const { isOpen, setIsOpen, triggerLayout } = usePopover(); const [contentSize, setContentSize] = useState({ width: 0, height: 0 }); const popoverColor = useColor('popover'); const borderColor = useColor('border'); const handleClose = () => { setIsOpen(false); }; // Calculate position based on side and align props const getPosition = () => { const screenDimensions = Dimensions.get('window'); const { x, y, width, height } = triggerLayout; // Use actual content size if available, otherwise use maxWidth/maxHeight const contentWidth = contentSize.width || maxWidth; const contentHeight = Math.min( contentSize.height || maxHeight, screenDimensions.height * 0.8 ); let top = 0; let left = 0; let actualSide = side; // Initial position calculation based on preferred side switch (side) { case 'top': top = y - contentHeight - sideOffset; break; case 'bottom': top = y + height + sideOffset; break; case 'left': left = x - contentWidth - sideOffset; break; case 'right': left = x + width + sideOffset; break; } // Calculate alignment for vertical sides (top/bottom) if (side === 'top' || side === 'bottom') { switch (align) { case 'start': left = x + alignOffset; break; case 'center': left = x + width / 2 - contentWidth / 2 + alignOffset; break; case 'end': left = x + width - contentWidth + alignOffset; break; } } // Calculate alignment for horizontal sides (left/right) else { switch (align) { case 'start': top = y + alignOffset; break; case 'center': top = y + height / 2 - contentHeight / 2 + alignOffset; break; case 'end': top = y + height - contentHeight + alignOffset; break; } } // Screen boundary adjustments with side flipping const padding = 16; // Check if we need to flip sides due to space constraints if (side === 'top' && top < padding) { // Not enough space on top, try bottom const bottomSpace = screenDimensions.height - (y + height + sideOffset); if (bottomSpace >= contentHeight) { actualSide = 'bottom'; top = y + height + sideOffset; } else { // Keep top but adjust position top = padding; } } else if ( side === 'bottom' && top + contentHeight > screenDimensions.height - padding ) { // Not enough space on bottom, try top const topSpace = y - sideOffset; if (topSpace >= contentHeight) { actualSide = 'top'; top = y - contentHeight - sideOffset; } else { // Keep bottom but adjust position top = screenDimensions.height - contentHeight - padding; } } else if (side === 'left' && left < padding) { // Not enough space on left, try right const rightSpace = screenDimensions.width - (x + width + sideOffset); if (rightSpace >= contentWidth) { actualSide = 'right'; left = x + width + sideOffset; } else { // Keep left but adjust position left = padding; } } else if ( side === 'right' && left + contentWidth > screenDimensions.width - padding ) { // Not enough space on right, try left const leftSpace = x - sideOffset; if (leftSpace >= contentWidth) { actualSide = 'left'; left = x - contentWidth - sideOffset; } else { // Keep right but adjust position left = screenDimensions.width - contentWidth - padding; } } // Final boundary adjustments (without side flipping) if (left < padding) { left = padding; } else if (left + contentWidth > screenDimensions.width - padding) { left = screenDimensions.width - contentWidth - padding; } if (top < padding) { top = padding; } else if (top + contentHeight > screenDimensions.height - padding) { top = screenDimensions.height - contentHeight - padding; } return { top: Math.max(padding, top), left: Math.max(padding, left), maxWidth, maxHeight: Math.min(maxHeight, screenDimensions.height - 2 * padding), actualSide, }; }; const position = getPosition(); const handleContentLayout = (event: any) => { const { width, height } = event.nativeEvent.layout; setContentSize({ width, height }); }; return ( <Modal visible={isOpen} transparent animationType='fade' onRequestClose={handleClose} > <Pressable style={styles.overlay} onPress={handleClose}> <View style={[ styles.content, { backgroundColor: popoverColor, borderColor: borderColor, top: position.top, left: position.left, maxWidth: position.maxWidth, maxHeight: position.maxHeight, }, style, ]} onLayout={handleContentLayout} onStartShouldSetResponder={() => true} accessibilityViewIsModal accessibilityRole='menu' > {children} </View> </Pressable> </Modal> ); } // Popover Header interface PopoverHeaderProps { children: ReactNode; style?: ViewStyle; } export function PopoverHeader({ children, style }: PopoverHeaderProps) { const borderColor = useColor('border'); return ( <View style={[styles.header, { borderBottomColor: borderColor }, style]}> {children} </View> ); } // Popover Body interface PopoverBodyProps { children: ReactNode; style?: ViewStyle; } export function PopoverBody({ children, style }: PopoverBodyProps) { return <View style={[styles.body, style]}>{children}</View>; } // Popover Footer interface PopoverFooterProps { children: ReactNode; style?: ViewStyle; } export function PopoverFooter({ children, style }: PopoverFooterProps) { const borderColor = useColor('border'); return ( <View style={[styles.footer, { borderTopColor: borderColor }, style]}> {children} </View> ); } // Popover Close (utility component) interface PopoverCloseProps { children: ReactNode; asChild?: boolean; style?: ViewStyle; } export function PopoverClose({ children, asChild = false, style, }: PopoverCloseProps) { const { setIsOpen } = usePopover(); const handlePress = () => { setIsOpen(false); }; if (asChild && React.isValidElement(children)) { return React.cloneElement(children, { onPress: handlePress, accessibilityRole: 'button', style: [(children.props as any).style, style], } as any); } return ( <TouchableOpacity style={style} onPress={handlePress} activeOpacity={0.7} accessibilityRole='button' > {children} </TouchableOpacity> ); } const styles = StyleSheet.create({ overlay: { flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.5)', }, content: { position: 'absolute', borderRadius: BORDER_RADIUS, borderWidth: 1, shadowColor: '#000', shadowOffset: { width: 0, height: 4, }, shadowOpacity: 0.25, shadowRadius: 6, elevation: 8, minWidth: 200, // Ensure minimum width }, header: { paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: 1, }, body: { padding: 16, }, footer: { paddingHorizontal: 16, paddingVertical: 12, borderTopWidth: 1, flexDirection: 'row', justifyContent: 'flex-end', gap: 8, }, }); ``` **2.** Update the import paths to match your project setup. ## Usage ```tsx import { Popover, PopoverBody, PopoverClose, PopoverContent, PopoverFooter, PopoverHeader, PopoverTrigger, } from '@/components/ui/popover'; ``` ```tsx <Popover> <PopoverTrigger> <Button>Open Popover</Button> </PopoverTrigger> <PopoverContent> <PopoverHeader> <Text>Popover Title</Text> </PopoverHeader> <PopoverBody> <Text>This is the popover content.</Text> </PopoverBody> <PopoverFooter> <PopoverClose> <Button variant='outline'>Close</Button> </PopoverClose> </PopoverFooter> </PopoverContent> </Popover> ``` ## Examples #### Default **Example:** A basic popover with trigger button and content ```tsx // components/demo/popover/popover-demo.tsx import { Button } from '@/components/ui/button'; import { Popover, PopoverBody, PopoverClose, PopoverContent, PopoverFooter, PopoverHeader, PopoverTrigger, } from '@/components/ui/popover'; import { Text } from '@/components/ui/text'; import React from 'react'; export function PopoverDemo() { return ( <Popover> <PopoverTrigger asChild> <Button>Open Popover</Button> </PopoverTrigger> <PopoverContent> <PopoverHeader> <Text variant='title'>Popover Title</Text> </PopoverHeader> <PopoverBody> <Text> This is the popover content. You can put any content here. </Text> </PopoverBody> <PopoverFooter> <PopoverClose> <Button variant='outline' size='sm'> Close </Button> </PopoverClose> </PopoverFooter> </PopoverContent> </Popover> ); } ``` #### Positioning **Example:** Popovers positioned on different sides of the trigger ```tsx // components/demo/popover/popover-positioning.tsx import { Button } from '@/components/ui/button'; import { Popover, PopoverBody, PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function PopoverPositioning() { return ( <View style={{ gap: 16, alignItems: 'center' }}> <Popover> <PopoverTrigger asChild> <Button style={{ width: 200 }}>Top</Button> </PopoverTrigger> <PopoverContent side='top'> <PopoverBody> <Text>Popover content positioned on top</Text> </PopoverBody> </PopoverContent> </Popover> <View style={{ flexDirection: 'row', gap: 16 }}> <Popover> <PopoverTrigger asChild> <Button style={{ flex: 1 }}>Left</Button> </PopoverTrigger> <PopoverContent side='left'> <PopoverBody> <Text>Left positioned</Text> </PopoverBody> </PopoverContent> </Popover> <Popover> <PopoverTrigger asChild> <Button style={{ flex: 1 }}>Right</Button> </PopoverTrigger> <PopoverContent side='right'> <PopoverBody> <Text>Right positioned</Text> </PopoverBody> </PopoverContent> </Popover> </View> <Popover> <PopoverTrigger asChild> <Button style={{ width: 200 }}>Bottom</Button> </PopoverTrigger> <PopoverContent side='bottom'> <PopoverBody> <Text>Popover content positioned on bottom</Text> </PopoverBody> </PopoverContent> </Popover> </View> ); } ``` #### Alignment **Example:** Popovers with different alignment options ```tsx // components/demo/popover/popover-alignment.tsx import { Button } from '@/components/ui/button'; import { Popover, PopoverBody, PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function PopoverAlignment() { return ( <View style={{ gap: 24, alignItems: 'center' }}> <View style={{ gap: 16 }}> <Text variant='title'>Bottom Side Alignment</Text> <View style={{ flexDirection: 'row', gap: 16 }}> <Popover> <PopoverTrigger asChild> <Button>Start</Button> </PopoverTrigger> <PopoverContent side='bottom' align='start'> <PopoverBody> <Text>Aligned to start (left)</Text> </PopoverBody> </PopoverContent> </Popover> <Popover> <PopoverTrigger asChild> <Button>Center</Button> </PopoverTrigger> <PopoverContent side='bottom' align='center'> <PopoverBody> <Text>Aligned to center</Text> </PopoverBody> </PopoverContent> </Popover> <Popover> <PopoverTrigger asChild> <Button>End</Button> </PopoverTrigger> <PopoverContent side='bottom' align='end'> <PopoverBody> <Text>Aligned to end (right)</Text> </PopoverBody> </PopoverContent> </Popover> </View> </View> <View style={{ gap: 16 }}> <Text variant='title'>Right Side Alignment</Text> <View style={{ gap: 16 }}> <Popover> <PopoverTrigger asChild> <Button>Start</Button> </PopoverTrigger> <PopoverContent side='right' align='start'> <PopoverBody> <Text>Aligned to start (top)</Text> </PopoverBody> </PopoverContent> </Popover> <Popover> <PopoverTrigger asChild> <Button>Center</Button> </PopoverTrigger> <PopoverContent side='right' align='center'> <PopoverBody> <Text>Aligned to center</Text> </PopoverBody> </PopoverContent> </Popover> <Popover> <PopoverTrigger asChild> <Button>End</Button> </PopoverTrigger> <PopoverContent side='right' align='end'> <PopoverBody> <Text>Aligned to end (bottom)</Text> </PopoverBody> </PopoverContent> </Popover> </View> </View> </View> ); } ``` #### Controlled **Example:** A controlled popover with external state management ```tsx // components/demo/popover/popover-controlled.tsx import { Button } from '@/components/ui/button'; import { Popover, PopoverBody, PopoverContent, PopoverHeader, PopoverTrigger, } from '@/components/ui/popover'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function PopoverControlled() { const [isOpen, setIsOpen] = useState(false); return ( <View style={{ gap: 16, alignItems: 'center' }}> <View style={{ flexDirection: 'row', gap: 12 }}> <Button variant={isOpen ? 'default' : 'outline'} onPress={() => setIsOpen(true)} > Open </Button> <Button variant={!isOpen ? 'default' : 'outline'} onPress={() => setIsOpen(false)} > Close </Button> </View> <Text>Status: {isOpen ? 'Open' : 'Closed'}</Text> <Popover open={isOpen} onOpenChange={setIsOpen}> <PopoverTrigger asChild> <Button>Controlled Popover</Button> </PopoverTrigger> <PopoverContent> <PopoverHeader> <Text variant='title'>Controlled Popover</Text> </PopoverHeader> <PopoverBody> <Text> This popover's state is controlled externally. You can open and close it using the buttons above or by clicking the trigger. </Text> </PopoverBody> </PopoverContent> </Popover> </View> ); } ``` #### Custom Content **Example:** Popovers with custom content and styling ```tsx // components/demo/popover/popover-custom.tsx import { Button } from '@/components/ui/button'; import { Popover, PopoverBody, PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import React from 'react'; export function PopoverCustom() { const primaryColor = useColor('primary'); const mutedColor = useColor('muted'); return ( <View style={{ gap: 16, alignItems: 'center' }}> <Popover> <PopoverTrigger asChild> <Button>Custom Styled</Button> </PopoverTrigger> <PopoverContent style={{ backgroundColor: primaryColor, borderRadius: 16, maxWidth: 250, }} > <PopoverBody style={{ padding: 20 }}> <Text style={{ color: 'black', textAlign: 'center' }}> This popover has custom styling with primary color background </Text> </PopoverBody> </PopoverContent> </Popover> <Popover> <PopoverTrigger asChild> <Button variant='outline'>Large Content</Button> </PopoverTrigger> <PopoverContent maxWidth={400} maxHeight={300} style={{ backgroundColor: mutedColor, }} > <PopoverBody style={{ padding: 24 }}> <Text variant='title' style={{ marginBottom: 12 }}> Large Popover Content </Text> <Text style={{ lineHeight: 20 }}> This popover has custom dimensions and can hold more content. It demonstrates how you can customize the appearance and size of popover components to fit your design needs. </Text> <Text style={{ marginTop: 12, fontStyle: 'italic' }}> The content area is scrollable if it exceeds the maximum height. </Text> </PopoverBody> </PopoverContent> </Popover> <Popover> <PopoverTrigger asChild> <Button size='icon'>?</Button> </PopoverTrigger> <PopoverContent side='top' align='center'> <PopoverBody> <Text style={{ textAlign: 'center' }}> This is a help tooltip using a custom circular trigger button </Text> </PopoverBody> </PopoverContent> </Popover> </View> ); } ``` #### Form Content **Example:** A popover containing form elements ```tsx // components/demo/popover/popover-form.tsx import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Popover, PopoverBody, PopoverClose, PopoverContent, PopoverFooter, PopoverHeader, PopoverTrigger, } from '@/components/ui/popover'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function PopoverForm() { const [name, setName] = useState(''); const [email, setEmail] = useState(''); const handleSubmit = () => { // Handle form submission console.log('Form submitted:', { name, email }); // Reset form setName(''); setEmail(''); }; return ( <Popover> <PopoverTrigger asChild> <Button>Add Contact</Button> </PopoverTrigger> <PopoverContent maxWidth={320}> <PopoverHeader> <Text variant='title'>Add New Contact</Text> </PopoverHeader> <PopoverBody> <View style={{ gap: 16 }}> <View> <Text style={{ marginBottom: 8, fontWeight: '500' }}>Name</Text> <Input placeholder='Enter full name' value={name} onChangeText={setName} /> </View> <View> <Text style={{ marginBottom: 8, fontWeight: '500' }}>Email</Text> <Input placeholder='Enter email address' value={email} onChangeText={setEmail} keyboardType='email-address' autoCapitalize='none' /> </View> </View> </PopoverBody> <PopoverFooter> <PopoverClose asChild> <Button variant='outline' size='sm'> Cancel </Button> </PopoverClose> <PopoverClose asChild> <Button size='sm' onPress={handleSubmit} disabled={!name.trim() || !email.trim()} > Add Contact </Button> </PopoverClose> </PopoverFooter> </PopoverContent> </Popover> ); } ``` #### Menu Style **Example:** A popover styled as a dropdown menu ```tsx // components/demo/popover/popover-menu.tsx import { Button } from '@/components/ui/button'; import { Popover, PopoverClose, PopoverContent, PopoverTrigger, } from '@/components/ui/popover'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import React from 'react'; import { TouchableOpacity } from 'react-native'; interface MenuItemProps { icon: string; label: string; onPress: () => void; destructive?: boolean; } function MenuItem({ icon, label, onPress, destructive = false, }: MenuItemProps) { const textColor = useColor(destructive ? 'destructive' : 'foreground'); const mutedColor = useColor('muted'); return ( <PopoverClose asChild> <TouchableOpacity style={{ flexDirection: 'row', alignItems: 'center', padding: 12, borderRadius: 6, }} onPress={onPress} activeOpacity={0.7} > <Text style={{ fontSize: 16, marginRight: 12, width: 20, textAlign: 'center', }} > {icon} </Text> <Text style={{ color: textColor, fontSize: 14 }}>{label}</Text> </TouchableOpacity> </PopoverClose> ); } export function PopoverMenu() { const borderColor = useColor('border'); const handleMenuAction = (action: string) => { console.log(`Menu action: ${action}`); }; return ( <View style={{ gap: 16, alignItems: 'center' }}> <Popover> <PopoverTrigger asChild> <Button variant='outline'>⚙️ Options</Button> </PopoverTrigger> <PopoverContent align='end' style={{ padding: 8, minWidth: 180, }} > <MenuItem icon='👤' label='View Profile' onPress={() => handleMenuAction('profile')} /> <MenuItem icon='⚙️' label='Settings' onPress={() => handleMenuAction('settings')} /> <MenuItem icon='📄' label='Export Data' onPress={() => handleMenuAction('export')} /> <View style={{ height: 1, backgroundColor: borderColor, marginVertical: 4, }} /> <MenuItem icon='🚪' label='Sign Out' onPress={() => handleMenuAction('signout')} /> <MenuItem icon='🗑️' label='Delete Account' onPress={() => handleMenuAction('delete')} destructive /> </PopoverContent> </Popover> <Popover> <PopoverTrigger asChild> <Button>✏️ Actions</Button> </PopoverTrigger> <PopoverContent side='bottom' align='start' style={{ padding: 8 }}> <MenuItem icon='📝' label='Edit' onPress={() => handleMenuAction('edit')} /> <MenuItem icon='📋' label='Copy' onPress={() => handleMenuAction('copy')} /> <MenuItem icon='📤' label='Share' onPress={() => handleMenuAction('share')} /> <MenuItem icon='⭐' label='Add to Favorites' onPress={() => handleMenuAction('favorite')} /> </PopoverContent> </Popover> </View> ); } ``` ## API Reference ### Popover The root component that manages the popover state and provides context. | Prop | Type | Default | Description | | -------------- | ------------------------- | ------- | ------------------------------------------- | | `children` | `ReactNode` | - | The popover trigger and content components. | | `open` | `boolean` | `false` | Controls the open state of the popover. | | `onOpenChange` | `(open: boolean) => void` | - | Callback fired when the open state changes. | ### PopoverTrigger The element that triggers the popover when pressed. | Prop | Type | Default | Description | | ---------- | ----------- | ------- | ---------------------------------------------------- | | `children` | `ReactNode` | - | The trigger content. | | `asChild` | `boolean` | `false` | When true, merges props with the child element. | | `style` | `ViewStyle` | - | Additional styles to apply to the trigger container. | ### PopoverContent The container for the popover content with positioning logic. | Prop | Type | Default | Description | | ------------- | ---------------------------------------- | -------- | ------------------------------------------------------ | | `children` | `ReactNode` | - | The popover content. | | `align` | `'start' \| 'center' \| 'end'` | `center` | How to align the popover relative to the trigger. | | `side` | `'top' \| 'right' \| 'bottom' \| 'left'` | `bottom` | The preferred side of the trigger to position against. | | `sideOffset` | `number` | `8` | The distance between the trigger and the popover. | | `alignOffset` | `number` | `0` | An offset in pixels from the alignment position. | | `style` | `ViewStyle` | - | Additional styles to apply to the content container. | | `maxWidth` | `number` | `300` | Maximum width of the popover in pixels. | | `maxHeight` | `number` | `400` | Maximum height of the popover in pixels. | ### PopoverHeader A header section for the popover content. | Prop | Type | Description | | ---------- | ----------- | --------------------------------------------------- | | `children` | `ReactNode` | The header content. | | `style` | `ViewStyle` | Additional styles to apply to the header container. | ### PopoverBody The main content area of the popover. | Prop | Type | Description | | ---------- | ----------- | ------------------------------------------------- | | `children` | `ReactNode` | The body content. | | `style` | `ViewStyle` | Additional styles to apply to the body container. | ### PopoverFooter A footer section for the popover content. | Prop | Type | Description | | ---------- | ----------- | --------------------------------------------------- | | `children` | `ReactNode` | The footer content. | | `style` | `ViewStyle` | Additional styles to apply to the footer container. | ### PopoverClose A utility component that closes the popover when pressed. | Prop | Type | Default | Description | | ---------- | ----------- | ------- | -------------------------------------------------- | | `children` | `ReactNode` | - | The close trigger content. | | `asChild` | `boolean` | `false` | When true, merges props with the child element. | | `style` | `ViewStyle` | - | Additional styles to apply to the close container. | ## Positioning The popover automatically positions itself relative to the trigger element and adjusts based on available screen space: - **Side**: Controls which side of the trigger the popover appears on (`top`, `right`, `bottom`, `left`) - **Alignment**: Controls how the popover aligns relative to the trigger (`start`, `center`, `end`) - **Auto-flipping**: When there's insufficient space, the popover automatically flips to the opposite side - **Boundary detection**: Ensures the popover stays within screen boundaries with appropriate padding ## Accessibility The Popover component is built with accessibility in mind: - Content is marked accessibilityViewIsModal with accessibilityRole="menu", scoping VoiceOver/TalkBack to it while open - Trigger and close controls expose accessibilityRole="button" and accessibilityState=\{\{ expanded }} - Tap-outside-to-dismiss via the backdrop for an easy close gesture - Touch-friendly interaction areas via the underlying Button component's sizing <!-- ---------------------------------------------------------------------- --> # Progress > A progress bar component to show completion status with optional interactivity. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/progress - Markdown: https://ui.ahmedbna.com/docs/components/progress.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/progress.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/progress.json - Install: `npx bna-ui add progress` - npm dependencies: `react-native-gesture-handler`, `react-native-reanimated`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `view` - Preview recording: https://demo.ahmedbna.com/0226-progress-demo.PNG --- **Example:** A basic progress bar showing completion status ```tsx // components/demo/progress/progress-demo.tsx import { Progress } from '@/components/ui/progress'; import React from 'react'; export function ProgressDemo() { return <Progress value={65} />; } ``` ## Installation ### CLI ```bash npx bna-ui add progress ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install react-native-gesture-handler react-native-reanimated ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/progress.tsx import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { HEIGHT } from '@/theme/globals'; import React, { useEffect } from 'react'; import { ViewStyle } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { runOnJS, useAnimatedStyle, useSharedValue, withTiming, } from 'react-native-reanimated'; interface ProgressProps { value: number; // 0-100 style?: ViewStyle; height?: number; onValueChange?: (value: number) => void; onSeekStart?: () => void; onSeekEnd?: () => void; interactive?: boolean; /** Amount to adjust by per accessibility increment/decrement action */ step?: number; } export function Progress({ value, style, height = HEIGHT, onValueChange, onSeekStart, onSeekEnd, interactive = false, step = 10, }: ProgressProps) { const primaryColor = useColor('primary'); const mutedColor = useColor('muted'); const clampedValue = Math.max(0, Math.min(100, value)); const progressWidth = useSharedValue(clampedValue); const containerWidth = useSharedValue(200); // Default width, will be updated const isDragging = useSharedValue(false); // Update animation when value prop changes (only if not dragging) useEffect(() => { if (!isDragging.value) { progressWidth.value = withTiming(clampedValue, { duration: 300 }); } }, [clampedValue]); const updateValue = (newValue: number) => { const clamped = Math.max(0, Math.min(100, newValue)); onValueChange?.(clamped); }; const handleAccessibilityAction = (event: { nativeEvent: { actionName: string }; }) => { if (!interactive) return; switch (event.nativeEvent.actionName) { case 'increment': updateValue(clampedValue + step); break; case 'decrement': updateValue(clampedValue - step); break; } }; const handleSeekStart = () => { isDragging.value = true; onSeekStart?.(); }; const handleSeekEnd = () => { isDragging.value = false; onSeekEnd?.(); }; // Create pan gesture using the new Gesture API const panGesture = Gesture.Pan() .onStart(() => { if (!interactive) return; runOnJS(handleSeekStart)(); }) .onUpdate((event) => { if (!interactive) return; // Calculate new progress based on gesture position const newProgress = (event.x / containerWidth.value) * 100; const clampedProgress = Math.max(0, Math.min(100, newProgress)); progressWidth.value = clampedProgress; runOnJS(updateValue)(clampedProgress); }) .onEnd(() => { if (!interactive) return; runOnJS(handleSeekEnd)(); }); // Create tap gesture for direct seeking const tapGesture = Gesture.Tap().onStart((event) => { if (!interactive) return; runOnJS(handleSeekStart)(); // Calculate progress based on tap position const newProgress = (event.x / containerWidth.value) * 100; const clampedProgress = Math.max(0, Math.min(100, newProgress)); progressWidth.value = withTiming(clampedProgress, { duration: 200 }); runOnJS(updateValue)(clampedProgress); setTimeout(() => { runOnJS(handleSeekEnd)(); }, 200); }); // Combine gestures const combinedGesture = Gesture.Race(panGesture, tapGesture); const animatedProgressStyle = useAnimatedStyle(() => { return { width: `${progressWidth.value}%`, }; }); const containerStyle: ViewStyle[] = [ { height: height, width: '100%' as const, backgroundColor: mutedColor, borderRadius: height / 2, overflow: 'hidden' as const, }, ...(style ? [style] : []), ]; const onLayout = (event: any) => { containerWidth.value = event.nativeEvent.layout.width; }; if (interactive) { return ( <GestureDetector gesture={combinedGesture}> <Animated.View style={containerStyle} onLayout={onLayout} accessible accessibilityRole='adjustable' accessibilityValue={{ min: 0, max: 100, now: clampedValue }} accessibilityActions={[ { name: 'increment', label: 'increment' }, { name: 'decrement', label: 'decrement' }, ]} onAccessibilityAction={handleAccessibilityAction} > <Animated.View style={[ { height: '100%' as const, backgroundColor: primaryColor, borderRadius: height / 2, }, animatedProgressStyle, ]} /> </Animated.View> </GestureDetector> ); } return ( <View style={containerStyle} onLayout={onLayout} accessible accessibilityRole='progressbar' accessibilityValue={{ min: 0, max: 100, now: clampedValue }} > <Animated.View style={[ { height: '100%' as const, backgroundColor: primaryColor, borderRadius: height / 2, }, animatedProgressStyle, ]} /> </View> ); } ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { Progress } from '@/components/ui/progress'; ``` ```tsx <Progress value={65} /> ``` ## Examples #### Default **Example:** A basic progress bar showing completion status ```tsx // components/demo/progress/progress-demo.tsx import { Progress } from '@/components/ui/progress'; import React from 'react'; export function ProgressDemo() { return <Progress value={65} />; } ``` #### Interactive **Example:** An interactive progress bar that can be dragged or tapped ```tsx // components/demo/progress/progress-interactive.tsx import { Progress } from '@/components/ui/progress'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function ProgressInteractive() { const [value, setValue] = useState(45); const [isSeking, setIsSeeking] = useState(false); return ( <View style={{ gap: 12 }}> <Text variant='body' style={{ color: '#666' }}> {isSeking ? 'Seeking...' : `Progress: ${Math.round(value)}%`} </Text> <Progress value={value} interactive height={18} onValueChange={setValue} onSeekStart={() => setIsSeeking(true)} onSeekEnd={() => setIsSeeking(false)} /> <Text variant='caption' style={{ color: '#999' }}> Tap or drag to adjust the progress </Text> </View> ); } ``` #### Custom Heights **Example:** Progress bars with different heights ```tsx // components/demo/progress/progress-heights.tsx import { Progress } from '@/components/ui/progress'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function ProgressHeights() { return ( <View style={{ gap: 16 }}> <View style={{ gap: 6 }}> <Text variant='caption'>Small (2px)</Text> <Progress value={75} height={2} /> </View> <View style={{ gap: 6 }}> <Text variant='caption'>Default (4px)</Text> <Progress value={60} /> </View> <View style={{ gap: 6 }}> <Text variant='caption'>Medium (8px)</Text> <Progress value={45} height={8} /> </View> <View style={{ gap: 6 }}> <Text variant='caption'>Large (12px)</Text> <Progress value={30} height={12} /> </View> <View style={{ gap: 6 }}> <Text variant='caption'>Extra Large (20px)</Text> <Progress value={85} height={20} /> </View> </View> ); } ``` #### With Labels **Example:** Progress bars with percentage labels and descriptions ```tsx // components/demo/progress/progress-labels.tsx import { Progress } from '@/components/ui/progress'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function ProgressLabels() { const tasks = [ { label: 'Installing dependencies', progress: 100 }, { label: 'Building application', progress: 75 }, { label: 'Running tests', progress: 45 }, { label: 'Deploying to production', progress: 0 }, ]; return ( <View style={{ gap: 20 }}> {tasks.map((task, index) => ( <View key={index} style={{ gap: 8 }}> <View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', }} > <Text variant='body' style={{ fontWeight: '500' }}> {task.label} </Text> <Text variant='caption' style={{ color: '#666' }}> {task.progress}% </Text> </View> <Progress value={task.progress} height={6} /> </View> ))} <View style={{ gap: 8, marginTop: 12 }}> <View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', }} > <Text variant='title' style={{ fontWeight: '600' }}> Overall Progress </Text> <Text variant='body' style={{ fontWeight: '500' }}> 55% </Text> </View> <Progress value={55} height={10} /> <Text variant='caption' style={{ color: '#666' }}> 2 of 4 tasks completed </Text> </View> </View> ); } ``` #### Animated **Example:** Progress bars with smooth animations and transitions ```tsx // components/demo/progress/progress-animated.tsx import { Progress } from '@/components/ui/progress'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useEffect, useState } from 'react'; export function ProgressAnimated() { const [progress1, setProgress1] = useState(0); const [progress2, setProgress2] = useState(0); const [progress3, setProgress3] = useState(0); useEffect(() => { // Animate first progress bar const timer1 = setTimeout(() => setProgress1(75), 500); // Animate second progress bar const timer2 = setTimeout(() => setProgress2(60), 1000); // Animate third progress bar const timer3 = setTimeout(() => setProgress3(85), 1500); return () => { clearTimeout(timer1); clearTimeout(timer2); clearTimeout(timer3); }; }, []); const [cycleProgress, setCycleProgress] = useState(0); useEffect(() => { const interval = setInterval(() => { setCycleProgress((prev) => { const newValue = prev + 10; return newValue > 100 ? 0 : newValue; }); }, 300); return () => clearInterval(interval); }, []); return ( <View style={{ gap: 20 }}> <View style={{ gap: 12 }}> <Text variant='title'>Staggered Animation</Text> <View style={{ gap: 8 }}> <Text variant='caption'>File Upload: {progress1}%</Text> <Progress value={progress1} height={6} /> </View> <View style={{ gap: 8 }}> <Text variant='caption'>Processing: {progress2}%</Text> <Progress value={progress2} height={6} /> </View> <View style={{ gap: 8 }}> <Text variant='caption'>Optimization: {progress3}%</Text> <Progress value={progress3} height={6} /> </View> </View> <View style={{ gap: 12 }}> <Text variant='title'>Continuous Animation</Text> <View style={{ gap: 8 }}> <Text variant='caption'>Loading: {cycleProgress}%</Text> <Progress value={cycleProgress} height={8} /> </View> </View> </View> ); } ``` #### Media Player Style **Example:** Progress bars styled for media player controls ```tsx // components/demo/progress/progress-media.tsx import { Progress } from '@/components/ui/progress'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; import { TouchableOpacity } from 'react-native'; export function ProgressMedia() { const [progress, setProgress] = useState(35); const [isPlaying, setIsPlaying] = useState(false); const [volume, setVolume] = useState(75); const formatTime = (percent: number) => { const totalSeconds = Math.floor((percent / 100) * 180); // 3 minute song const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return `${minutes}:${seconds.toString().padStart(2, '0')}`; }; return ( <View style={{ gap: 20 }}> {/* Media Player */} <View style={{ backgroundColor: '#1a1a1a', borderRadius: 12, padding: 16, gap: 12, }} > <View style={{ gap: 8 }}> <Text variant='body' style={{ color: '#fff', fontWeight: '600' }}> Song Title </Text> <Text variant='caption' style={{ color: '#999' }}> Artist Name </Text> </View> <View style={{ gap: 8 }}> <Progress value={progress} interactive height={4} onValueChange={setProgress} style={{ backgroundColor: '#333' }} /> <View style={{ flexDirection: 'row', justifyContent: 'space-between', }} > <Text variant='caption' style={{ color: '#999' }}> {formatTime(progress)} </Text> <Text variant='caption' style={{ color: '#999' }}> 3:00 </Text> </View> </View> <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 20, }} > <TouchableOpacity style={{ width: 48, height: 48, borderRadius: 24, backgroundColor: '#007AFF', justifyContent: 'center', alignItems: 'center', }} onPress={() => setIsPlaying(!isPlaying)} > <Text style={{ color: '#fff', fontSize: 20 }}> {isPlaying ? '⏸️' : '▶️'} </Text> </TouchableOpacity> </View> </View> {/* Volume Control */} <View style={{ backgroundColor: '#f8f9fa', borderRadius: 8, padding: 12, gap: 8, }} > <View style={{ flexDirection: 'row', alignItems: 'center', gap: 12, }} > <Text>🔊</Text> <View style={{ flex: 1 }}> <Progress value={volume} interactive height={6} onValueChange={setVolume} /> </View> <Text variant='caption' style={{ color: '#666' }}> {Math.round(volume)}% </Text> </View> </View> </View> ); } ``` #### Step Progress **Example:** Multi-step progress indicators ```tsx // components/demo/progress/progress-steps.tsx import { Progress } from '@/components/ui/progress'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; import { TouchableOpacity } from 'react-native'; export function ProgressSteps() { const [currentStep, setCurrentStep] = useState(2); const steps = ['Account Setup', 'Personal Info', 'Verification', 'Complete']; const progress = (currentStep / (steps.length - 1)) * 100; return ( <View style={{ gap: 20 }}> {/* Step Progress */} <View style={{ gap: 16 }}> <View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', }} > <Text variant='title'>Setup Progress</Text> <Text variant='caption' style={{ color: '#666' }}> Step {currentStep + 1} of {steps.length} </Text> </View> <Progress value={progress} height={8} /> <View style={{ flexDirection: 'row', justifyContent: 'space-between', marginTop: 8, }} > {steps.map((step, index) => ( <View key={index} style={{ alignItems: 'center', flex: 1, }} > <View style={{ width: 24, height: 24, borderRadius: 12, backgroundColor: index <= currentStep ? '#007AFF' : '#e5e7eb', justifyContent: 'center', alignItems: 'center', marginBottom: 8, }} > <Text variant='caption' style={{ color: index <= currentStep ? '#fff' : '#666', fontWeight: '600', }} > {index < currentStep ? '✓' : index + 1} </Text> </View> <Text variant='caption' style={{ color: index <= currentStep ? '#000' : '#999', fontWeight: index === currentStep ? '600' : '400', textAlign: 'center', }} > {step} </Text> </View> ))} </View> </View> {/* Controls */} <View style={{ flexDirection: 'row', gap: 12, justifyContent: 'center', }} > <TouchableOpacity style={{ paddingHorizontal: 16, paddingVertical: 8, backgroundColor: currentStep > 0 ? '#007AFF' : '#e5e7eb', borderRadius: 6, }} onPress={() => setCurrentStep(Math.max(0, currentStep - 1))} disabled={currentStep === 0} > <Text style={{ color: currentStep > 0 ? '#fff' : '#999', fontWeight: '500', }} > Previous </Text> </TouchableOpacity> <TouchableOpacity style={{ paddingHorizontal: 16, paddingVertical: 8, backgroundColor: currentStep < steps.length - 1 ? '#007AFF' : '#e5e7eb', borderRadius: 6, }} onPress={() => setCurrentStep(Math.min(steps.length - 1, currentStep + 1)) } disabled={currentStep === steps.length - 1} > <Text style={{ color: currentStep < steps.length - 1 ? '#fff' : '#999', fontWeight: '500', }} > Next </Text> </TouchableOpacity> </View> </View> ); } ``` ## API Reference ### Progress The main progress bar component. | Prop | Type | Default | Description | | --------------- | ------------------------- | --------------- | --------------------------------------------------------------------------------------------- | | `value` | `number` | - | The progress value between 0-100. | | `style` | `ViewStyle` | - | Additional styles to apply to the progress container. | | `height` | `number` | `HEIGHT` (`48`) | The height of the progress bar in pixels. | | `onValueChange` | `(value: number) => void` | - | Callback fired when the progress value changes (interactive). | | `onSeekStart` | `() => void` | - | Callback fired when seeking starts (interactive). | | `onSeekEnd` | `() => void` | - | Callback fired when seeking ends (interactive). | | `interactive` | `boolean` | `false` | Whether the progress bar can be interacted with (tap/drag). | | `step` | `number` | `10` | Amount to adjust `value` by per accessibility increment/decrement action, when `interactive`. | ## Accessibility The Progress component is built with accessibility in mind: - Exposes `accessibilityRole="progressbar"` (or `"adjustable"` when `interactive`) with `accessibilityValue={{ min: 0, max: 100, now: value }}` - When `interactive`, `accessibilityActions` (increment/decrement) let screen-reader users adjust the value without the drag gesture ## Interactive Features When `interactive` is set to `true`, the Progress component supports: - **Tap to seek**: Tap anywhere on the progress bar to jump to that position - **Drag to scrub**: Drag the progress indicator to scrub through values - **Smooth animations**: Animated transitions between values - **Callbacks**: Get notified when seeking starts, changes, or ends This makes it perfect for media players, volume controls, or any scenario where users need to adjust a value by interacting with the progress bar. <!-- ---------------------------------------------------------------------- --> # Radio > A set of checkable buttons—known as radio buttons—where no more than one of the buttons can be checked at a time. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/radio - Markdown: https://ui.ahmedbna.com/docs/components/radio.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/radio.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/radio.json - Install: `npx bna-ui add radio` - npm dependencies: `expo-haptics`, `react-native` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `useHaptics`, `globals`, `text` - Preview recording: https://demo.ahmedbna.com/0233-radio-demo.MP4 --- **Example:** A basic radio group with multiple options ```tsx // components/demo/radio/radio-demo.tsx import { RadioGroup } from '@/components/ui/radio'; import React, { useState } from 'react'; export function RadioDemo() { const [value, setValue] = useState('option1'); return ( <RadioGroup options={[ { label: 'Default', value: 'option1' }, { label: 'Comfortable', value: 'option2' }, { label: 'Compact', value: 'option3' }, ]} value={value} onValueChange={setValue} /> ); } ``` ## Installation ### CLI ```bash npx bna-ui add radio ``` ### Manual **1.** Copy and paste the following code into your project. ```tsx // components/ui/radio.tsx import { Text } from '@/components/ui/text'; import { useColor } from '@/hooks/useColor'; import { useHaptics } from '@/hooks/useHaptics'; import { BORDER_RADIUS, CORNERS, FONT_SIZE } from '@/theme/globals'; import React from 'react'; import { TextStyle, TouchableOpacity, View, ViewStyle } from 'react-native'; export interface RadioOption { label: string; value: string; disabled?: boolean; } interface RadioGroupProps { options: RadioOption[]; value?: string; onValueChange?: (value: string) => void; disabled?: boolean; orientation?: 'vertical' | 'horizontal'; style?: ViewStyle; optionStyle?: ViewStyle; labelStyle?: TextStyle; haptic?: boolean; } interface RadioButtonProps { option: RadioOption; selected: boolean; onPress: () => void; disabled?: boolean; style?: ViewStyle; labelStyle?: TextStyle; haptic?: boolean; } export function RadioButton({ option, selected, onPress, disabled = false, style, labelStyle, haptic = true, }: RadioButtonProps) { const primaryColor = useColor('primary'); const borderColor = useColor('border'); const textColor = useColor('text'); const mutedColor = useColor('textMuted'); const feedback = useHaptics(haptic); const isDisabled = disabled || option.disabled; // Re-tapping the option that is already selected is a no-op, so it should not // feel like one. RadioGroup deliberately does not fire its own — this is the // single source of feedback for the interaction. const handlePress = () => { if (!selected) feedback('selection'); onPress(); }; const radioButtonStyle: ViewStyle = { width: BORDER_RADIUS, height: BORDER_RADIUS, borderRadius: CORNERS, borderWidth: 1.5, borderColor: selected ? primaryColor : borderColor, backgroundColor: 'transparent', alignItems: 'center', justifyContent: 'center', marginRight: 12, }; const innerCircleStyle: ViewStyle = { width: 16, height: 16, borderRadius: CORNERS, backgroundColor: selected ? primaryColor : 'transparent', }; const containerStyle: ViewStyle = { flexDirection: 'row', alignItems: 'center', paddingVertical: 4, paddingHorizontal: 4, opacity: isDisabled ? 0.5 : 1, }; const textStyle: TextStyle = { color: isDisabled ? mutedColor : textColor, fontSize: FONT_SIZE, fontWeight: '400', lineHeight: 24, }; return ( <TouchableOpacity style={[containerStyle, style]} onPress={handlePress} disabled={isDisabled} activeOpacity={0.7} hitSlop={{ top: 9, bottom: 9, left: 9, right: 9 }} accessibilityRole='radio' accessibilityState={{ checked: selected, disabled: isDisabled }} accessibilityLabel={option.label} > <View style={radioButtonStyle}> <View style={innerCircleStyle} /> </View> <Text style={[textStyle, labelStyle]}>{option.label}</Text> </TouchableOpacity> ); } export function RadioGroup({ options, value, onValueChange, disabled = false, orientation = 'vertical', style, optionStyle, labelStyle, haptic = true, }: RadioGroupProps) { const containerStyle: ViewStyle = { flexDirection: orientation === 'horizontal' ? 'row' : 'column', gap: orientation === 'horizontal' ? 16 : 4, }; const handlePress = (optionValue: string) => { if (onValueChange && !disabled) { onValueChange(optionValue); } }; return ( <View style={[containerStyle, style]} accessibilityRole='radiogroup'> {options.map((option) => ( <RadioButton key={option.value} option={option} selected={value === option.value} onPress={() => handlePress(option.value)} disabled={disabled} style={optionStyle} labelStyle={labelStyle} haptic={haptic} /> ))} </View> ); } ``` **2.** Update the import paths to match your project setup. ## Usage ```tsx import { RadioGroup, RadioButton } from '@/components/ui/radio'; ``` ```tsx const [value, setValue] = useState('option1'); <RadioGroup options={[ { label: 'Option 1', value: 'option1' }, { label: 'Option 2', value: 'option2' }, { label: 'Option 3', value: 'option3' }, ]} value={value} onValueChange={setValue} />; ``` ## Examples #### Default **Example:** A basic radio group with multiple options ```tsx // components/demo/radio/radio-demo.tsx import { RadioGroup } from '@/components/ui/radio'; import React, { useState } from 'react'; export function RadioDemo() { const [value, setValue] = useState('option1'); return ( <RadioGroup options={[ { label: 'Default', value: 'option1' }, { label: 'Comfortable', value: 'option2' }, { label: 'Compact', value: 'option3' }, ]} value={value} onValueChange={setValue} /> ); } ``` #### Horizontal Layout **Example:** Radio buttons arranged horizontally ```tsx // components/demo/radio/radio-horizontal.tsx import { RadioGroup } from '@/components/ui/radio'; import React, { useState } from 'react'; export function RadioHorizontal() { const [value, setValue] = useState('small'); return ( <RadioGroup orientation='horizontal' options={[ { label: 'Small', value: 'small' }, { label: 'Medium', value: 'medium' }, { label: 'Large', value: 'large' }, ]} value={value} onValueChange={setValue} /> ); } ``` #### Disabled Options **Example:** Radio group with some disabled options ```tsx // components/demo/radio/radio-disabled.tsx import { RadioGroup } from '@/components/ui/radio'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function RadioDisabled() { const [value1, setValue1] = useState('option1'); const [value2, setValue2] = useState('option2'); return ( <View style={{ gap: 24 }}> {/* Some disabled options */} <View> <Text style={{ marginBottom: 12, fontWeight: '500' }}> With disabled options </Text> <RadioGroup options={[ { label: 'Available', value: 'option1' }, { label: 'Disabled', value: 'option2', disabled: true }, { label: 'Available', value: 'option3' }, { label: 'Disabled', value: 'option4', disabled: true }, ]} value={value1} onValueChange={setValue1} /> </View> {/* Entire group disabled */} <View> <Text style={{ marginBottom: 12, fontWeight: '500' }}> Entire group disabled </Text> <RadioGroup disabled options={[ { label: 'Option 1', value: 'option1' }, { label: 'Option 2', value: 'option2' }, { label: 'Option 3', value: 'option3' }, ]} value={value2} onValueChange={setValue2} /> </View> </View> ); } ``` #### Custom Styling **Example:** Radio buttons with custom colors and styling ```tsx // components/demo/radio/radio-styled.tsx import { RadioGroup } from '@/components/ui/radio'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import React, { useState } from 'react'; export function RadioStyled() { const green = useColor('green'); const card = useColor('card'); const [value1, setValue1] = useState('red'); const [value2, setValue2] = useState('plan1'); return ( <View style={{ gap: 24 }}> {/* Custom colors */} <View> <Text style={{ marginBottom: 12, fontWeight: '500' }}> Card-like options </Text> <RadioGroup options={[ { label: 'Red Theme', value: 'red' }, { label: 'Blue Theme', value: 'blue' }, { label: 'Green Theme', value: 'green' }, ]} value={value1} onValueChange={setValue1} optionStyle={{ paddingVertical: 12, paddingHorizontal: 12, backgroundColor: card, borderRadius: 8, marginBottom: 4, }} labelStyle={{ fontSize: 16, fontWeight: '500', }} /> </View> {/* Card-like styling */} <View> <Text style={{ marginBottom: 12, fontWeight: '500' }}> Custom styling </Text> <RadioGroup options={[ { label: 'Basic Plan - $9/month', value: 'plan1' }, { label: 'Pro Plan - $19/month', value: 'plan2' }, { label: 'Enterprise - $49/month', value: 'plan3' }, ]} value={value2} onValueChange={setValue2} optionStyle={{ paddingVertical: 16, paddingHorizontal: 16, backgroundColor: green, borderRadius: 12, marginBottom: 8, shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.05, shadowRadius: 2, elevation: 1, }} labelStyle={{ fontSize: 15, fontWeight: '500', color: '#1f2937', }} /> </View> </View> ); } ``` #### Form Integration **Example:** Radio group integrated with form validation ```tsx // components/demo/radio/radio-form.tsx import { Button } from '@/components/ui/button'; import { RadioGroup } from '@/components/ui/radio'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; import { Alert } from 'react-native'; export function RadioForm() { const [experience, setExperience] = useState(''); const [notification, setNotification] = useState('email'); const [theme, setTheme] = useState('system'); const handleSubmit = () => { if (!experience) { Alert.alert('Error', 'Please select your experience level'); return; } Alert.alert( 'Form Submitted', `Experience: ${experience}\nNotifications: ${notification}\nTheme: ${theme}` ); }; return ( <View style={{ paddingVertical: 16, gap: 24 }}> <Text style={{ fontSize: 18, fontWeight: '600' }}>User Preferences</Text> <View> <Text style={{ marginBottom: 12, fontWeight: '500', fontSize: 16 }}> Experience Level * </Text> <RadioGroup options={[ { label: 'Beginner', value: 'beginner' }, { label: 'Intermediate', value: 'intermediate' }, { label: 'Advanced', value: 'advanced' }, { label: 'Expert', value: 'expert' }, ]} value={experience} onValueChange={setExperience} /> </View> <View> <Text style={{ marginBottom: 12, fontWeight: '500', fontSize: 16 }}> Notification Preference </Text> <RadioGroup options={[ { label: 'Email notifications', value: 'email' }, { label: 'Push notifications', value: 'push' }, { label: 'SMS notifications', value: 'sms' }, { label: 'No notifications', value: 'none' }, ]} value={notification} onValueChange={setNotification} /> </View> <View> <Text style={{ marginBottom: 12, fontWeight: '500', fontSize: 16 }}> Theme Preference </Text> <RadioGroup orientation='horizontal' options={[ { label: 'Light', value: 'light' }, { label: 'Dark', value: 'dark' }, { label: 'System', value: 'system' }, ]} value={theme} onValueChange={setTheme} /> </View> <Button onPress={handleSubmit} style={{ marginTop: 8 }}> Save Preferences </Button> </View> ); } ``` #### Large Size **Example:** Radio buttons with larger size and spacing ```tsx // components/demo/radio/radio-large.tsx import { RadioGroup } from '@/components/ui/radio'; import React, { useState } from 'react'; export function RadioLarge() { const [value, setValue] = useState('option1'); return ( <RadioGroup options={[ { label: 'Large Option One', value: 'option1' }, { label: 'Large Option Two', value: 'option2' }, { label: 'Large Option Three', value: 'option3' }, ]} value={value} onValueChange={setValue} style={{ gap: 12 }} optionStyle={{ paddingVertical: 12, }} labelStyle={{ fontSize: 18, fontWeight: '500', lineHeight: 28, }} /> ); } ``` #### Single Radio Button **Example:** Individual radio button component usage ```tsx // components/demo/radio/radio-single.tsx import { RadioButton } from '@/components/ui/radio'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function RadioSingle() { const [selectedValue, setSelectedValue] = useState('option2'); const options = [ { label: 'First Option', value: 'option1' }, { label: 'Second Option', value: 'option2' }, { label: 'Third Option', value: 'option3' }, { label: 'Disabled Option', value: 'option4', disabled: true }, ]; return ( <View style={{ gap: 16 }}> <Text style={{ fontWeight: '500', fontSize: 16 }}> Individual Radio Buttons </Text> <View style={{ gap: 8 }}> {options.map((option) => ( <RadioButton key={option.value} option={option} selected={selectedValue === option.value} onPress={() => setSelectedValue(option.value)} /> ))} </View> <Text variant='caption'>Selected: {selectedValue}</Text> </View> ); } ``` ## API Reference ### RadioGroup The main container component that manages a group of radio buttons. Uses `value`/`onValueChange` (matching `ToggleGroup`'s group-selection convention) rather than `checkbox`'s `checked`/`onCheckedChange` or `toggle`'s `pressed`/`onPressedChange` — each naming convention matches its own control's interaction model and is intentional, not an inconsistency. | Prop | Type | Default | Description | | --------------- | ---------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------- | | `haptic` | `boolean` | `true` | Whether to trigger haptic feedback when an option is selected. Forwarded to every `RadioButton` in the group. | | `options` | `RadioOption[]` | - | Array of radio button options. | | `value` | `string` | - | The currently selected value. | | `onValueChange` | `(value: string) => void` | - | Callback fired when the selection changes. | | `disabled` | `boolean` | `false` | Whether the entire group is disabled. | | `orientation` | `'vertical' \| 'horizontal'` | `'vertical'` | Layout orientation of the radio buttons. | | `style` | `ViewStyle` | - | Additional styles for the group container. | | `optionStyle` | `ViewStyle` | - | Additional styles for each radio button. | | `labelStyle` | `TextStyle` | - | Additional styles for radio button labels. | ### RadioButton Individual radio button component for custom layouts. | Prop | Type | Default | Description | | ------------ | ------------- | ------- | ------------------------------------------------------------------------------------------------------------ | | `haptic` | `boolean` | `true` | Whether to trigger haptic feedback when the option is selected. Re-selecting the active option stays silent. | | `option` | `RadioOption` | - | The radio option data. | | `selected` | `boolean` | - | Whether this radio button is selected. | | `onPress` | `() => void` | - | Callback fired when the button is pressed. | | `disabled` | `boolean` | `false` | Whether this radio button is disabled. | | `style` | `ViewStyle` | - | Additional styles for the button container. | | `labelStyle` | `TextStyle` | - | Additional styles for the button label. | ### RadioOption The shape of each radio option object. | Prop | Type | Description | | ---------- | --------- | ----------------------------------------- | | `label` | `string` | The display text for the radio button. | | `value` | `string` | The value associated with this option. | | `disabled` | `boolean` | Whether this specific option is disabled. | ## Accessibility The Radio component is built with accessibility in mind: - Uses TouchableOpacity for proper touch feedback - Supports disabled states with appropriate visual feedback - Proper opacity changes for disabled options - Screen reader friendly with semantic structure - Supports keyboard navigation patterns - Clear visual indicators for selected state <!-- ---------------------------------------------------------------------- --> # ScrollView > A scrollable view component that allows content to be scrolled when it exceeds the container size. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/scroll-view - Markdown: https://ui.ahmedbna.com/docs/components/scroll-view.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/scroll-view.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/scroll-view.json - Install: `npx bna-ui add scroll-view` - Preview recording: https://demo.ahmedbna.com/0240-scroll-view-demo.MP4 --- **Example:** A basic scrollable view with content ```tsx // components/demo/scroll-view/scroll-view-demo.tsx import { ScrollView } from '@/components/ui/scroll-view'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { BORDER_RADIUS } from '@/theme/globals'; import React from 'react'; export function ScrollViewDemo() { const card = useColor('card'); return ( <View style={{ height: 200, borderWidth: 1, borderColor: card, borderRadius: BORDER_RADIUS, }} > <ScrollView style={{ padding: 16 }}> {Array.from({ length: 20 }, (_, i) => ( <Text key={i} style={{ marginBottom: 8, padding: 12, backgroundColor: card, borderRadius: 6, }} > Scrollable item {i + 1} </Text> ))} </ScrollView> </View> ); } ``` ## Installation ### CLI ```bash npx bna-ui add scroll-view ``` ### Manual **1.** Copy and paste the following code into your project. ```tsx // components/ui/scroll-view.tsx import { forwardRef } from 'react'; import { ScrollView as RNScrollView, ScrollViewProps } from 'react-native'; export const ScrollView = forwardRef<RNScrollView, ScrollViewProps>( ({ style, ...otherProps }, ref) => { return ( <RNScrollView ref={ref} style={[{ backgroundColor: 'transparent' }, style]} {...otherProps} /> ); } ); ``` **2.** Update the import paths to match your project setup. ## Usage ```tsx import { ScrollView } from '@/components/ui/scroll-view'; ``` ```tsx <ScrollView> <Text>Your scrollable content here</Text> </ScrollView> ``` ## Examples #### Default **Example:** A basic scrollable view with content ```tsx // components/demo/scroll-view/scroll-view-demo.tsx import { ScrollView } from '@/components/ui/scroll-view'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { BORDER_RADIUS } from '@/theme/globals'; import React from 'react'; export function ScrollViewDemo() { const card = useColor('card'); return ( <View style={{ height: 200, borderWidth: 1, borderColor: card, borderRadius: BORDER_RADIUS, }} > <ScrollView style={{ padding: 16 }}> {Array.from({ length: 20 }, (_, i) => ( <Text key={i} style={{ marginBottom: 8, padding: 12, backgroundColor: card, borderRadius: 6, }} > Scrollable item {i + 1} </Text> ))} </ScrollView> </View> ); } ``` #### Vertical Scrolling **Example:** Vertical scrolling with multiple items ```tsx // components/demo/scroll-view/scroll-view-vertical.tsx import { ScrollView } from '@/components/ui/scroll-view'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { BORDER_RADIUS } from '@/theme/globals'; import React from 'react'; export function ScrollViewVertical() { const colors = [ '#ef4444', '#f97316', '#eab308', '#22c55e', '#3b82f6', '#8b5cf6', '#ec4899', ]; return ( <View style={{ height: 300, borderWidth: 1, borderColor: '#e5e7eb', borderRadius: BORDER_RADIUS, }} > <ScrollView contentContainerStyle={{ padding: 16, gap: 12 }} showsVerticalScrollIndicator={true} > {Array.from({ length: 15 }, (_, i) => ( <View key={i} style={{ height: 80, backgroundColor: colors[i % colors.length], borderRadius: 12, justifyContent: 'center', alignItems: 'center', }} > <Text style={{ color: 'white', fontWeight: 'bold', fontSize: 16 }}> Card {i + 1} </Text> </View> ))} </ScrollView> </View> ); } ``` #### Horizontal Scrolling **Example:** Horizontal scrolling with cards ```tsx // components/demo/scroll-view/scroll-view-horizontal.tsx import { ScrollView } from '@/components/ui/scroll-view'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { BORDER_RADIUS } from '@/theme/globals'; import React from 'react'; export function ScrollViewHorizontal() { const gradients = [ ['#ff9a9e', '#fecfef'], ['#a18cd1', '#fbc2eb'], ['#fad0c4', '#ffd1ff'], ['#ffecd2', '#fcb69f'], ['#a8edea', '#fed6e3'], ['#d299c2', '#fef9d7'], ['#89f7fe', '#66a6ff'], ]; return ( <View style={{ height: 150, borderWidth: 1, borderColor: '#e5e7eb', borderRadius: BORDER_RADIUS, }} > <ScrollView horizontal={true} contentContainerStyle={{ padding: 16, gap: 16, alignItems: 'center' }} showsHorizontalScrollIndicator={true} > {Array.from({ length: 10 }, (_, i) => ( <View key={i} style={{ width: 120, height: 100, backgroundColor: gradients[i % gradients.length][0], borderRadius: 12, justifyContent: 'center', alignItems: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 4, elevation: 3, }} > <Text style={{ color: 'white', fontWeight: 'bold', fontSize: 14 }}> Item {i + 1} </Text> </View> ))} </ScrollView> </View> ); } ``` #### Nested ScrollViews **Example:** ScrollViews nested within each other ```tsx // components/demo/scroll-view/scroll-view-nested.tsx import { ScrollView } from '@/components/ui/scroll-view'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { BORDER_RADIUS } from '@/theme/globals'; import React from 'react'; export function ScrollViewNested() { return ( <View style={{ height: 300, borderWidth: 1, borderColor: '#e5e7eb', borderRadius: BORDER_RADIUS, }} > <ScrollView contentContainerStyle={{ padding: 16 }}> <Text style={{ fontSize: 18, fontWeight: 'bold', marginBottom: 16 }}> Vertical Scroll </Text> {Array.from({ length: 3 }, (_, sectionIndex) => ( <View key={sectionIndex} style={{ marginBottom: 24 }}> <Text style={{ fontSize: 16, fontWeight: '600', marginBottom: 12 }}> Section {sectionIndex + 1} </Text> <View style={{ height: 120, borderWidth: 1, borderColor: '#d1d5db', borderRadius: BORDER_RADIUS, }} > <ScrollView horizontal={true} contentContainerStyle={{ padding: 12, gap: 12, alignItems: 'center', }} showsHorizontalScrollIndicator={true} > {Array.from({ length: 8 }, (_, itemIndex) => ( <View key={itemIndex} style={{ width: 80, height: 80, backgroundColor: '#3b82f6', borderRadius: BORDER_RADIUS, justifyContent: 'center', alignItems: 'center', }} > <Text style={{ color: 'white', fontSize: 12, fontWeight: 'bold', }} > {sectionIndex + 1}.{itemIndex + 1} </Text> </View> ))} </ScrollView> </View> </View> ))} <Text style={{ padding: 16, borderRadius: BORDER_RADIUS, textAlign: 'center', }} > End of scrollable content </Text> </ScrollView> </View> ); } ``` #### With Pull to Refresh **Example:** ScrollView with pull-to-refresh functionality ```tsx // components/demo/scroll-view/scroll-view-refresh.tsx import { ScrollView } from '@/components/ui/scroll-view'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { BORDER_RADIUS } from '@/theme/globals'; import React, { useCallback, useState } from 'react'; import { RefreshControl } from 'react-native'; export function ScrollViewRefresh() { const card = useColor('card'); const green = useColor('green'); const [refreshing, setRefreshing] = useState(false); const [lastRefresh, setLastRefresh] = useState( new Date().toLocaleTimeString() ); const onRefresh = useCallback(() => { setRefreshing(true); setTimeout(() => { setRefreshing(false); setLastRefresh(new Date().toLocaleTimeString()); }, 2000); }, []); return ( <View style={{ height: 300, borderWidth: 1, borderColor: '#e5e7eb', borderRadius: BORDER_RADIUS, }} > <ScrollView contentContainerStyle={{ padding: 16 }} refreshControl={ <RefreshControl refreshing={refreshing} onRefresh={onRefresh} /> } > <View style={{ padding: 16, backgroundColor: green, borderRadius: BORDER_RADIUS, marginBottom: 16, }} > <Text style={{ fontWeight: 'bold', color: '#000', marginBottom: 4 }}> Pull to Refresh </Text> <Text style={{ color: '#047857' }}> Last refreshed: {lastRefresh} </Text> </View> {Array.from({ length: 15 }, (_, i) => ( <View key={i} style={{ padding: 16, backgroundColor: card, borderRadius: BORDER_RADIUS, marginBottom: 8, borderLeftWidth: 4, borderLeftColor: '#3b82f6', }} > <Text style={{ fontWeight: '600', marginBottom: 4 }}> News Item {i + 1} </Text> <Text style={{ color: '#6b7280' }}> This is a sample news item that demonstrates the pull-to-refresh functionality. </Text> </View> ))} </ScrollView> </View> ); } ``` #### Custom Styling **Example:** ScrollView with custom styling and padding ```tsx // components/demo/scroll-view/scroll-view-styled.tsx import { ScrollView } from '@/components/ui/scroll-view'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function ScrollViewStyled() { return ( <View style={{ height: 300, borderRadius: 16, overflow: 'hidden' }}> <ScrollView style={{ backgroundColor: '#1f2937', borderRadius: 16, }} contentContainerStyle={{ padding: 20, gap: 16, }} showsVerticalScrollIndicator={true} > <View style={{ padding: 20, backgroundColor: '#374151', borderRadius: 12, borderWidth: 1, borderColor: '#4b5563', }} > <Text style={{ color: '#f9fafb', fontSize: 18, fontWeight: 'bold', marginBottom: 8, }} > 🌙 Dark Theme ScrollView </Text> <Text style={{ color: '#d1d5db' }}> This ScrollView uses custom dark styling with rounded corners and shadows. </Text> </View> {Array.from({ length: 12 }, (_, i) => ( <View key={i} style={{ padding: 16, backgroundColor: i % 2 === 0 ? '#6366f1' : '#8b5cf6', borderRadius: 12, shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.3, shadowRadius: 8, elevation: 6, }} > <Text style={{ color: 'white', fontWeight: 'bold', fontSize: 16, marginBottom: 4, }} > Card {i + 1} </Text> <Text style={{ color: '#e5e7eb', opacity: 0.9 }}> Beautiful custom styled card with gradient-like colors and shadows. </Text> </View> ))} <View style={{ padding: 20, backgroundColor: '#059669', borderRadius: 12, alignItems: 'center', }} > <Text style={{ color: 'white', fontWeight: 'bold' }}> ✨ End of styled content </Text> </View> </ScrollView> </View> ); } ``` #### Scroll Indicators **Example:** ScrollView with custom scroll indicators ```tsx // components/demo/scroll-view/scroll-view-indicators.tsx import { ScrollView } from '@/components/ui/scroll-view'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { BORDER_RADIUS } from '@/theme/globals'; import React from 'react'; export function ScrollViewIndicators() { const card = useColor('card'); return ( <View style={{ gap: 16 }}> {/* Vertical with indicators */} <View> <Text style={{ fontWeight: 'bold', marginBottom: 8 }}> With Scroll Indicators </Text> <View style={{ height: 200, borderWidth: 1, borderColor: '#e5e7eb', borderRadius: BORDER_RADIUS, }} > <ScrollView contentContainerStyle={{ padding: 16, gap: 8 }} showsVerticalScrollIndicator={true} indicatorStyle='white' > {Array.from({ length: 12 }, (_, i) => ( <Text key={i} style={{ padding: 12, backgroundColor: card, borderRadius: BORDER_RADIUS, }} > Item {i + 1} - Scroll indicators visible </Text> ))} </ScrollView> </View> </View> {/* Horizontal without indicators */} <View style={{ marginTop: 8 }}> <Text style={{ fontWeight: 'bold', marginBottom: 8 }}> Without Scroll Indicators </Text> <View style={{ height: 150, borderWidth: 1, borderColor: '#e5e7eb', borderRadius: BORDER_RADIUS, }} > <ScrollView horizontal={true} contentContainerStyle={{ padding: 16, gap: 12, alignItems: 'center', }} showsHorizontalScrollIndicator={false} > {Array.from({ length: 8 }, (_, i) => ( <View key={i} style={{ width: 80, height: 60, backgroundColor: '#fbbf24', borderRadius: BORDER_RADIUS, justifyContent: 'center', alignItems: 'center', }} > <Text style={{ color: 'white', fontWeight: 'bold' }}> {i + 1} </Text> </View> ))} </ScrollView> </View> </View> </View> ); } ``` #### Content Inset **Example:** ScrollView with content inset adjustments ```tsx // components/demo/scroll-view/scroll-view-inset.tsx import { ScrollView } from '@/components/ui/scroll-view'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { BORDER_RADIUS } from '@/theme/globals'; import React from 'react'; export function ScrollViewInset() { const card = useColor('card'); return ( <View style={{ gap: 16 }}> {/* Standard ScrollView */} <View> <Text style={{ fontWeight: 'bold', marginBottom: 8 }}> Standard Content </Text> <View style={{ height: 150, borderWidth: 1, borderColor: '#e5e7eb', borderRadius: BORDER_RADIUS, }} > <ScrollView contentContainerStyle={{ padding: 16 }}> {Array.from({ length: 8 }, (_, i) => ( <Text key={i} style={{ padding: 12, marginBottom: 8, backgroundColor: card, borderRadius: BORDER_RADIUS, }} > Standard item {i + 1} </Text> ))} </ScrollView> </View> </View> {/* ScrollView with content inset */} <View> <Text style={{ fontWeight: 'bold', marginBottom: 8 }}> With Content Inset Adjustments </Text> <View style={{ height: 150, borderWidth: 1, borderColor: '#e5e7eb', borderRadius: BORDER_RADIUS, }} > <ScrollView contentContainerStyle={{ paddingTop: 32, paddingBottom: 32, paddingHorizontal: 24, }} contentInset={{ top: 20, bottom: 20 }} contentInsetAdjustmentBehavior='automatic' > <View style={{ padding: 16, backgroundColor: '#ddd6fe', borderRadius: BORDER_RADIUS, marginBottom: 16, borderWidth: 2, borderColor: '#8b5cf6', }} > <Text style={{ fontWeight: 'bold', color: '#5b21b6' }}> Header with Inset </Text> </View> {Array.from({ length: 6 }, (_, i) => ( <Text key={i} style={{ padding: 12, marginBottom: 8, backgroundColor: '#fef3c7', borderRadius: BORDER_RADIUS, borderLeftWidth: 3, borderLeftColor: '#f59e0b', }} > Inset adjusted item {i + 1} </Text> ))} <View style={{ padding: 16, backgroundColor: '#dcfce7', borderRadius: BORDER_RADIUS, marginTop: 8, borderWidth: 2, borderColor: '#22c55e', }} > <Text style={{ fontWeight: 'bold', color: '#15803d' }}> Footer with Inset </Text> </View> </ScrollView> </View> </View> </View> ); } ``` ## API Reference ### ScrollView A wrapper around React Native's ScrollView with enhanced styling capabilities. All other props from React Native's ScrollView are also supported. | Prop | Type | Default | Description | | -------------------------------- | -------------------------------------- | --------- | ----------------------------------------------------------------------------- | | `style` | `ViewStyle` | - | Additional styles to apply to the scroll view. | | `contentContainerStyle` | `ViewStyle` | - | Styles applied to the scroll view content container. | | `horizontal` | `boolean` | `false` | When true, the scroll view's children are arranged horizontally. | | `showsVerticalScrollIndicator` | `boolean` | `true` | When true, shows a vertical scroll indicator. | | `showsHorizontalScrollIndicator` | `boolean` | `true` | When true, shows a horizontal scroll indicator. | | `scrollEnabled` | `boolean` | `true` | When false, the content does not scroll. | | `bounces` | `boolean` | `true` | When true, the scroll view bounces when it reaches the end. | | `bouncesZoom` | `boolean` | `true` | When true, gestures can drive zoom past min/max. | | `alwaysBounceVertical` | `boolean` | `false` | When true, the scroll view bounces vertically even when content is smaller. | | `alwaysBounceHorizontal` | `boolean` | `false` | When true, the scroll view bounces horizontally even when content is smaller. | | `pagingEnabled` | `boolean` | `false` | When true, the scroll view stops on multiples of the scroll view's size. | | `scrollEventThrottle` | `number` | - | Controls how often the scroll event will be fired while scrolling. | | `onScroll` | `function` | - | Fires at most once per frame during scrolling. | | `onScrollBeginDrag` | `function` | - | Called when the user begins to drag the scroll view. | | `onScrollEndDrag` | `function` | - | Called when the user stops dragging the scroll view. | | `onMomentumScrollBegin` | `function` | - | Called when the momentum scroll starts. | | `onMomentumScrollEnd` | `function` | - | Called when the momentum scroll ends. | | `refreshControl` | `RefreshControl` | - | A RefreshControl component for pull-to-refresh functionality. | | `keyboardDismissMode` | `'none' \| 'on-drag' \| 'interactive'` | `'none'` | Determines when the keyboard is dismissed. | | `keyboardShouldPersistTaps` | `'always' \| 'never' \| 'handled'` | `'never'` | Determines when the keyboard should stay visible after a tap. | ## Accessibility The ScrollView component maintains React Native's built-in accessibility features: - Automatically announces scrollable content to screen readers - Supports gesture-based navigation for users with accessibility needs - Maintains proper focus management during scrolling - Compatible with VoiceOver and TalkBack - Supports dynamic text sizing and high contrast modes <!-- ---------------------------------------------------------------------- --> # SearchBar > A customizable search input with debouncing, loading states, and suggestions. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/searchbar - Markdown: https://ui.ahmedbna.com/docs/components/searchbar.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/searchbar.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/searchbar.json - Install: `npx bna-ui add searchbar` - npm dependencies: `lucide-react-native`, `react-native-svg` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `text`, `view`, `icon` - Preview recording: https://demo.ahmedbna.com/0248-searchbar-demo.MP4 --- **Example:** A basic search bar with search functionality ```tsx // components/demo/searchbar/searchbar-demo.tsx import { SearchBar } from '@/components/ui/searchbar'; import React, { useState } from 'react'; export function SearchBarDemo() { const [searchQuery, setSearchQuery] = useState(''); const handleSearch = (query: string) => { console.log('Searching for:', query); }; return ( <SearchBar placeholder='Search for anything...' value={searchQuery} onChangeText={setSearchQuery} onSearch={handleSearch} /> ); } ``` ## Installation ### CLI ```bash npx bna-ui add searchbar ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install lucide-react-native ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/searchbar.tsx import { Icon } from '@/components/ui/icon'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { CORNERS, FONT_SIZE, HEIGHT } from '@/theme/globals'; import { Search, X } from 'lucide-react-native'; import React, { useCallback, useRef, useState } from 'react'; import { ActivityIndicator, TextInput, TextInputProps, TextStyle, TouchableOpacity, ViewStyle, } from 'react-native'; interface SearchBarProps extends Omit<TextInputProps, 'style'> { loading?: boolean; onSearch?: (query: string) => void; onClear?: () => void; showClearButton?: boolean; leftIcon?: React.ReactNode; rightIcon?: React.ReactNode; containerStyle?: ViewStyle | ViewStyle[]; inputStyle?: TextStyle | TextStyle[]; debounceMs?: number; } export function SearchBar({ loading = false, onSearch, onClear, showClearButton = true, leftIcon, rightIcon, containerStyle, inputStyle, debounceMs = 300, placeholder = 'Search...', value, onChangeText, ...props }: SearchBarProps) { const [internalValue, setInternalValue] = useState(value || ''); const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); const inputRef = useRef<TextInput>(null); // Theme colors const cardColor = useColor('card'); const textColor = useColor('text'); const muted = useColor('textMuted'); const icon = useColor('icon'); // Handle text change with debouncing const handleTextChange = useCallback( (text: string) => { setInternalValue(text); onChangeText?.(text); if (onSearch && debounceMs > 0) { if (debounceRef.current) { clearTimeout(debounceRef.current); } (debounceRef.current as any) = setTimeout(() => { onSearch(text); }, debounceMs); } else if (onSearch) { onSearch(text); } }, [onChangeText, onSearch, debounceMs] ); // Handle clear button press const handleClear = useCallback(() => { setInternalValue(''); onChangeText?.(''); onClear?.(); onSearch?.(''); if (debounceRef.current) { clearTimeout(debounceRef.current); } }, [onChangeText, onClear, onSearch]); // Get container style based on variant and size const baseStyle: ViewStyle = { flexDirection: 'row', alignItems: 'center', backgroundColor: cardColor, height: HEIGHT, paddingHorizontal: 16, borderRadius: CORNERS, }; const baseInputStyle = { flex: 1, fontSize: FONT_SIZE, color: textColor, marginHorizontal: 8, }; const displayValue = value !== undefined ? value : internalValue; const showClear = showClearButton && displayValue.length > 0; return ( <View style={[baseStyle, containerStyle]}> {/* Left Icon */} {leftIcon || <Icon name={Search} size={16} color={muted} />} {/* Text Input */} <TextInput ref={inputRef} style={[baseInputStyle, inputStyle]} placeholder={placeholder} placeholderTextColor={muted} value={displayValue} onChangeText={handleTextChange} accessibilityRole='search' {...props} /> {/* Loading Indicator */} {loading && ( <ActivityIndicator size='small' color={muted} style={{ marginRight: 4 }} /> )} {/* Clear Button */} {showClear && !loading && ( <TouchableOpacity onPress={handleClear} style={{ backgroundColor: icon, padding: 4, borderRadius: CORNERS, opacity: 0.6, }} activeOpacity={0.7} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} accessibilityRole='button' accessibilityLabel='Clear search' > <Icon name={X} size={16} color={cardColor} strokeWidth={2} /> </TouchableOpacity> )} {/* Right Icon */} {rightIcon && !showClear && !loading && rightIcon} </View> ); } // SearchBar with suggestions dropdown interface SearchBarWithSuggestionsProps extends SearchBarProps { suggestions?: string[]; onSuggestionPress?: (suggestion: string) => void; maxSuggestions?: number; showSuggestions?: boolean; } export function SearchBarWithSuggestions({ suggestions = [], onSuggestionPress, maxSuggestions = 5, showSuggestions = true, containerStyle, ...searchBarProps }: SearchBarWithSuggestionsProps) { const [isExpanded, setIsExpanded] = useState(false); const cardColor = useColor('card'); const borderColor = useColor('border'); const filteredSuggestions = suggestions .filter((suggestion) => suggestion .toLowerCase() .includes((searchBarProps.value || '').toLowerCase()) ) .slice(0, maxSuggestions); const shouldShowSuggestions = showSuggestions && isExpanded && filteredSuggestions.length > 0 && (searchBarProps.value || '').length > 0; const handleSuggestionPress = (suggestion: string) => { onSuggestionPress?.(suggestion); setIsExpanded(false); }; return ( <View style={[{ width: '100%' }, containerStyle]}> <SearchBar {...searchBarProps} onFocus={(e) => { setIsExpanded(true); searchBarProps.onFocus?.(e); }} onBlur={(e) => { // Delay hiding suggestions to allow for suggestion tap setTimeout(() => setIsExpanded(false), 150); searchBarProps.onBlur?.(e); }} /> {/* Suggestions Dropdown */} {shouldShowSuggestions && ( <View style={{ position: 'absolute', top: '100%', left: 0, right: 0, backgroundColor: cardColor, marginTop: 8, borderRadius: 12, maxHeight: 200, zIndex: 999, }} > {filteredSuggestions.map((suggestion, index) => ( <TouchableOpacity key={`${suggestion}-${index}`} onPress={() => handleSuggestionPress(suggestion)} style={{ paddingHorizontal: 16, paddingVertical: 12, borderBottomWidth: index < filteredSuggestions.length - 1 ? 0.6 : 0, borderBottomColor: borderColor, }} activeOpacity={0.7} > <Text>{suggestion}</Text> </TouchableOpacity> ))} </View> )} </View> ); } ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { SearchBar, SearchBarWithSuggestions } from '@/components/ui/searchbar'; ``` ```tsx <SearchBar placeholder='Search for anything...' onSearch={(query) => console.log('Searching for:', query)} loading={false} /> ``` ## Examples #### Default **Example:** A basic search bar with search functionality ```tsx // components/demo/searchbar/searchbar-demo.tsx import { SearchBar } from '@/components/ui/searchbar'; import React, { useState } from 'react'; export function SearchBarDemo() { const [searchQuery, setSearchQuery] = useState(''); const handleSearch = (query: string) => { console.log('Searching for:', query); }; return ( <SearchBar placeholder='Search for anything...' value={searchQuery} onChangeText={setSearchQuery} onSearch={handleSearch} /> ); } ``` #### With Loading State **Example:** Search bar with loading indicator ```tsx // components/demo/searchbar/searchbar-loading.tsx import { SearchBar } from '@/components/ui/searchbar'; import React, { useState } from 'react'; export function SearchBarLoading() { const [searchQuery, setSearchQuery] = useState(''); const [loading, setLoading] = useState(false); const handleSearch = (query: string) => { if (query.trim()) { setLoading(true); // Simulate API call setTimeout(() => { setLoading(false); console.log('Search completed for:', query); }, 2000); } }; return ( <SearchBar placeholder='Search with loading state...' value={searchQuery} onChangeText={setSearchQuery} onSearch={handleSearch} loading={loading} /> ); } ``` #### Custom Icons **Example:** Search bar with custom left and right icons ```tsx // components/demo/searchbar/searchbar-icons.tsx import { Icon } from '@/components/ui/icon'; import { SearchBar } from '@/components/ui/searchbar'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { Filter, MapPin, User } from 'lucide-react-native'; import React, { useState } from 'react'; export function SearchBarIcons() { const [locationQuery, setLocationQuery] = useState(''); const [userQuery, setUserQuery] = useState(''); const icon = useColor('icon'); return ( <View style={{ gap: 16 }}> {/* Location search with map pin icon */} <SearchBar placeholder='Search locations...' value={locationQuery} onChangeText={setLocationQuery} leftIcon={<Icon name={MapPin} size={16} color={icon} />} onSearch={(query) => console.log('Location search:', query)} /> {/* User search with custom icons */} <SearchBar placeholder='Search users...' value={userQuery} onChangeText={setUserQuery} leftIcon={<Icon name={User} size={16} color={icon} />} rightIcon={<Icon name={Filter} size={16} color={icon} />} showClearButton={false} onSearch={(query) => console.log('User search:', query)} /> </View> ); } ``` #### With Suggestions **Example:** Search bar with dropdown suggestions ```tsx // components/demo/searchbar/searchbar-suggestions.tsx import { SearchBarWithSuggestions } from '@/components/ui/searchbar'; import React, { useState } from 'react'; export function SearchBarSuggestions() { const [searchQuery, setSearchQuery] = useState(''); const suggestions = [ 'React Native', 'React Navigation', 'React Hook Form', 'Redux Toolkit', 'Expo Router', 'TypeScript', 'JavaScript', 'Node.js', 'Next.js', 'Tailwind CSS', ]; const handleSearch = (query: string) => { console.log('Searching for:', query); }; const handleSuggestionPress = (suggestion: string) => { setSearchQuery(suggestion); handleSearch(suggestion); }; return ( <SearchBarWithSuggestions placeholder='Type to see suggestions...' value={searchQuery} onChangeText={setSearchQuery} onSearch={handleSearch} suggestions={suggestions} onSuggestionPress={handleSuggestionPress} maxSuggestions={8} /> ); } ``` #### Custom Styling **Example:** Search bar with custom styling and colors ```tsx // components/demo/searchbar/searchbar-styled.tsx import { SearchBar } from '@/components/ui/searchbar'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function SearchBarStyled() { const [query1, setQuery1] = useState(''); const [query2, setQuery2] = useState(''); const [query3, setQuery3] = useState(''); return ( <View style={{ gap: 16 }}> {/* Rounded with gradient-like background */} <SearchBar placeholder='Rounded search...' value={query1} onChangeText={setQuery1} containerStyle={{ borderRadius: 25, borderWidth: 1.5, borderColor: 'green', }} inputStyle={{ color: 'green', fontWeight: '500', }} /> {/* Minimal flat design */} <SearchBar placeholder='Minimal search...' value={query2} onChangeText={setQuery2} containerStyle={{ backgroundColor: 'transparent', borderBottomWidth: 1, borderBottomColor: '#374151', borderRadius: 0, paddingHorizontal: 0, }} inputStyle={{ fontSize: 16, fontWeight: '400', }} /> {/* Dark theme with custom height */} <SearchBar placeholder='Custom dark search...' value={query3} onChangeText={setQuery3} containerStyle={{ backgroundColor: '#1f2937', borderRadius: 12, height: 56, borderWidth: 1, borderColor: '#374151', }} inputStyle={{ color: '#f9fafb', fontSize: 16, }} /> </View> ); } ``` #### Without Clear Button **Example:** Search bar without the clear button ```tsx // components/demo/searchbar/searchbar-no-clear.tsx import { SearchBar } from '@/components/ui/searchbar'; import React, { useState } from 'react'; export function SearchBarNoClear() { const [searchQuery, setSearchQuery] = useState(''); const handleSearch = (query: string) => { console.log('Searching without clear button:', query); }; return ( <SearchBar placeholder='Search without clear button...' value={searchQuery} onChangeText={setSearchQuery} onSearch={handleSearch} showClearButton={false} /> ); } ``` #### Instant Search **Example:** Search bar with no debounce for instant search ```tsx // components/demo/searchbar/searchbar-instant.tsx import { SearchBar } from '@/components/ui/searchbar'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function SearchBarInstant() { const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState<string[]>([]); const mockData = [ 'Apple', 'Banana', 'Cherry', 'Date', 'Elderberry', 'Fig', 'Grape', 'Honeydew', 'Kiwi', 'Lemon', ]; const handleInstantSearch = (query: string) => { if (query.trim()) { const results = mockData.filter((item) => item.toLowerCase().includes(query.toLowerCase()) ); setSearchResults(results); } else { setSearchResults([]); } }; return ( <View style={{ gap: 16 }}> <SearchBar placeholder='Instant search (no debounce)...' value={searchQuery} onChangeText={setSearchQuery} onSearch={handleInstantSearch} debounceMs={0} // No debounce for instant search /> {searchResults.length > 0 && ( <View style={{ gap: 8 }}> <Text variant='caption' style={{ opacity: 0.7 }}> Found {searchResults.length} results: </Text> {searchResults.map((result, index) => ( <Text key={index} style={{ paddingLeft: 16 }}> • {result} </Text> ))} </View> )} </View> ); } ``` ## API Reference ### SearchBar The main search input component with debouncing and customization options. All other `TextInputProps` are also supported. | Prop | Type | Default | Description | | ----------------- | -------------------------- | ----------- | ------------------------------------------------------ | | `loading` | `boolean` | `false` | Shows loading indicator when true. | | `onSearch` | `(query: string) => void` | - | Callback fired when search is triggered (debounced). | | `onClear` | `() => void` | - | Callback fired when clear button is pressed. | | `showClearButton` | `boolean` | `true` | Whether to show the clear button when text is present. | | `leftIcon` | `ReactNode` | Search icon | Custom left icon component. | | `rightIcon` | `ReactNode` | - | Custom right icon component. | | `containerStyle` | `ViewStyle \| ViewStyle[]` | - | Additional styles for the container. | | `inputStyle` | `TextStyle \| TextStyle[]` | - | Additional styles for the text input. | | `debounceMs` | `number` | `300` | Debounce delay in milliseconds for search callbacks. | | `placeholder` | `string` | 'Search...' | Placeholder text for the input. | | `value` | `string` | - | Controlled value of the input. | | `onChangeText` | `(text: string) => void` | - | Callback fired when text changes. | ### SearchBarWithSuggestions An enhanced search bar with dropdown suggestions functionality. All `SearchBar` props are also supported. | Prop | Type | Default | Description | | ------------------- | ------------------------------ | ------- | --------------------------------------------- | | `suggestions` | `string[]` | `[]` | Array of suggestion strings to display. | | `onSuggestionPress` | `(suggestion: string) => void` | - | Callback fired when a suggestion is selected. | | `maxSuggestions` | `number` | `5` | Maximum number of suggestions to display. | | `showSuggestions` | `boolean` | `true` | Whether to show the suggestions dropdown. | ## Features - **Debounced Search**: Configurable debounce delay to prevent excessive API calls - **Loading States**: Built-in loading indicator support - **Clear Functionality**: Optional clear button with customizable behavior - **Custom Icons**: Support for custom left and right icons - **Suggestions Dropdown**: Enhanced variant with autocomplete suggestions - **Flexible Styling**: Comprehensive styling options for container and input - **Controlled/Uncontrolled**: Supports both controlled and uncontrolled usage patterns - **Accessibility**: Built with accessibility best practices ## Accessibility The SearchBar component follows accessibility guidelines: - Proper focus management and keyboard navigation - Screen reader compatible with appropriate labels - Clear button is properly labeled for assistive technologies - Suggestions dropdown supports keyboard navigation - High contrast support for better visibility <!-- ---------------------------------------------------------------------- --> # Separator > Visually or semantically separates content. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/separator - Markdown: https://ui.ahmedbna.com/docs/components/separator.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/separator.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/separator.json - Install: `npx bna-ui add separator` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `view` - Preview recording: https://demo.ahmedbna.com/0255-separator-demo.PNG --- **Example:** A basic horizontal separator ```tsx // components/demo/separator/separator-demo.tsx import { Separator } from '@/components/ui/separator'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function SeparatorDemo() { return ( <View style={{ padding: 16 }}> <Text variant='body'>Above separator</Text> <Separator style={{ marginVertical: 16 }} /> <Text variant='body'>Below separator</Text> </View> ); } ``` ## Installation ### CLI ```bash npx bna-ui add separator ``` ### Manual **1.** Copy and paste the following code into your project. ```tsx // components/ui/separator.tsx import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import React from 'react'; import { ViewStyle } from 'react-native'; interface SeparatorProps { orientation?: 'horizontal' | 'vertical'; style?: ViewStyle; } export function Separator({ orientation = 'horizontal', style, }: SeparatorProps) { const borderColor = useColor('border'); return ( <View accessibilityElementsHidden importantForAccessibility='no-hide-descendants' style={[ { backgroundColor: borderColor, ...(orientation === 'horizontal' ? { height: 1, width: '100%' } : { width: 1, height: '100%' }), }, style, ]} /> ); } ``` **2.** Update the import paths to match your project setup. ## Usage ```tsx import { Separator } from '@/components/ui/separator'; ``` ```tsx <Separator /> ``` ## Examples #### Default **Example:** A basic horizontal separator ```tsx // components/demo/separator/separator-demo.tsx import { Separator } from '@/components/ui/separator'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function SeparatorDemo() { return ( <View style={{ padding: 16 }}> <Text variant='body'>Above separator</Text> <Separator style={{ marginVertical: 16 }} /> <Text variant='body'>Below separator</Text> </View> ); } ``` #### Vertical **Example:** A vertical separator for inline content ```tsx // components/demo/separator/separator-vertical.tsx import { Separator } from '@/components/ui/separator'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function SeparatorVertical() { return ( <View style={{ flexDirection: 'row', alignItems: 'center', padding: 16, height: 60, }} > <Text variant='body'>Left content</Text> <Separator orientation='vertical' style={{ marginHorizontal: 16 }} /> <Text variant='body'>Right content</Text> </View> ); } ``` #### Custom Thickness **Example:** Separators with different thickness values ```tsx // components/demo/separator/separator-thickness.tsx import { Separator } from '@/components/ui/separator'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function SeparatorThickness() { return ( <View style={{ padding: 16 }}> <Text variant='caption' style={{ marginBottom: 8 }}> Thin (1px) </Text> <Separator style={{ height: 1, marginBottom: 16 }} /> <Text variant='caption' style={{ marginBottom: 8 }}> Medium (2px) </Text> <Separator style={{ height: 2, marginBottom: 16 }} /> <Text variant='caption' style={{ marginBottom: 8 }}> Thick (4px) </Text> <Separator style={{ height: 4, marginBottom: 16 }} /> <Text variant='caption' style={{ marginBottom: 8 }}> Extra thick (8px) </Text> <Separator style={{ height: 8 }} /> </View> ); } ``` #### Custom Colors **Example:** Separators with custom colors and opacity ```tsx // components/demo/separator/separator-colors.tsx import { Separator } from '@/components/ui/separator'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function SeparatorColors() { return ( <View style={{ padding: 16 }}> <Text variant='caption' style={{ marginBottom: 8 }}> Default </Text> <Separator style={{ marginBottom: 16 }} /> <Text variant='caption' style={{ marginBottom: 8 }}> Red </Text> <Separator style={{ backgroundColor: '#ef4444', marginBottom: 16 }} /> <Text variant='caption' style={{ marginBottom: 8 }}> Blue </Text> <Separator style={{ backgroundColor: '#3b82f6', marginBottom: 16 }} /> <Text variant='caption' style={{ marginBottom: 8 }}> Green </Text> <Separator style={{ backgroundColor: '#10b981', marginBottom: 16 }} /> <Text variant='caption' style={{ marginBottom: 8 }}> Semi-transparent </Text> <Separator style={{ backgroundColor: 'rgba(0, 0, 0, 0.2)' }} /> </View> ); } ``` #### Spacing Variants **Example:** Separators with different margin and padding ```tsx // components/demo/separator/separator-spacing.tsx import { Separator } from '@/components/ui/separator'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function SeparatorSpacing() { return ( <View style={{ padding: 16 }}> <Text variant='body'>Tight spacing</Text> <Separator style={{ marginVertical: 4 }} /> <Text variant='body'>Content with minimal spacing</Text> <Separator style={{ marginVertical: 12 }} /> <Text variant='body'>Normal spacing</Text> <Separator style={{ marginVertical: 16 }} /> <Text variant='body'>Standard content spacing</Text> <Separator style={{ marginVertical: 12 }} /> <Text variant='body'>Loose spacing</Text> <Separator style={{ marginVertical: 24 }} /> <Text variant='body'>Generous content spacing</Text> </View> ); } ``` ## API Reference ### Separator A component that creates visual separation between content elements. | Prop | Type | Default | Description | | ------------- | ---------------------------- | -------------- | -------------------------------------------- | | `orientation` | `'horizontal' \| 'vertical'` | `'horizontal'` | The orientation of the separator. | | `style` | `ViewStyle` | - | Additional styles to apply to the separator. | ## Accessibility The Separator component is built with accessibility in mind: - Uses appropriate semantic structure for screen readers - Provides visual separation that maintains proper contrast ratios - Works well with keyboard navigation flows - Respects system accessibility settings for reduced motion <!-- ---------------------------------------------------------------------- --> # Share > A button component for sharing content across platforms with native share functionality. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/share - Markdown: https://ui.ahmedbna.com/docs/components/share.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/share.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/share.json - Install: `npx bna-ui add share` - npm dependencies: `expo-haptics`, `lucide-react-native`, `react-native-reanimated`, `react-native-svg`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `useHaptics`, `text`, `view`, `icon`, `spinner`, `button` - Preview recording: https://demo.ahmedbna.com/0260-share-demo.MP4 --- **Example:** A basic share button with text and URL sharing ```tsx // components/demo/share/share-demo.tsx import { ShareButton } from '@/components/ui/share'; import React from 'react'; export function ShareDemo() { return ( <ShareButton content={{ message: 'Check out this amazing app!', url: 'https://example.com', title: 'Amazing App', }} onShareSuccess={(activityType) => { console.log('Shared successfully:', activityType); }} onShareError={(error) => { console.error('Share failed:', error); }} > Share </ShareButton> ); } ``` ## Installation ### CLI ```bash npx bna-ui add share ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install lucide-react-native ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/share.tsx import { Button, ButtonVariant } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { useColor } from '@/hooks/useColor'; import { FONT_SIZE } from '@/theme/globals'; import { Share as ShareIcon } from 'lucide-react-native'; import React, { useCallback, useMemo } from 'react'; import { Alert, Platform, Share as RNShare, ShareOptions, TextStyle, View, } from 'react-native'; export interface ShareContent { message?: string; url?: string; title?: string; subject?: string; // For email sharing on iOS } export interface ShareButtonOptions { dialogTitle?: string; // Android only excludedActivityTypes?: string[]; // iOS only tintColor?: string; // iOS only anchor?: number; // iOS only - for iPad anchoring } interface ShareButtonProps { content: ShareContent; options?: ShareButtonOptions; children?: React.ReactNode; variant?: ButtonVariant; size?: 'default' | 'sm' | 'lg' | 'icon'; disabled?: boolean; loading?: boolean; onShareStart?: () => void; onShareSuccess?: (activityType?: string | null) => void; onShareError?: (error: Error) => void; onShareDismiss?: () => void; showIcon?: boolean; iconSize?: number; fallbackMessage?: string; validateContent?: boolean; testID?: string; } export function ShareButton({ content, options, children, variant = 'default', size = 'default', disabled = false, loading = false, onShareStart, onShareSuccess, onShareError, onShareDismiss, showIcon = true, iconSize = 18, fallbackMessage, validateContent = true, testID, }: ShareButtonProps) { const primaryColor = useColor('primary'); const primaryForegroundColor = useColor('primaryForeground'); const secondaryForegroundColor = useColor('secondaryForeground'); const destructiveForegroundColor = useColor('destructiveForeground'); // Validate content requirements const isContentValid = useMemo(() => { if (!validateContent) return true; // At least one of url or message is required const hasRequiredContent = Boolean(content.message || content.url); // Additional validation if (content.url && !isValidUrl(content.url)) { return false; } return hasRequiredContent; }, [content, validateContent]); const handleShare = useCallback(async () => { if (!isContentValid) { const error = new Error( 'Invalid share content: At least one of message or url is required' ); onShareError?.(error); Alert.alert('Share Error', 'Cannot share: invalid content provided'); return; } try { onShareStart?.(); // Build share content object const shareContent: any = {}; const message = content.message || fallbackMessage; if (message) shareContent.message = message; if (content.url) shareContent.url = content.url; // `title` is Android-facing per RN's Share API (iOS ignores it) if (Platform.OS === 'android' && content.title) { shareContent.title = content.title; } // Build share options object const shareOptions: ShareOptions = {}; // `subject` is iOS-facing and belongs on options, not content if (Platform.OS === 'ios' && content.subject) { shareOptions.subject = content.subject; } if (options) { // Android-specific options if (Platform.OS === 'android' && options.dialogTitle) { shareOptions.dialogTitle = options.dialogTitle; } // iOS-specific options if (Platform.OS === 'ios') { if (options.excludedActivityTypes) { shareOptions.excludedActivityTypes = options.excludedActivityTypes; } if (options.tintColor) { shareOptions.tintColor = options.tintColor; } if (options.anchor) { shareOptions.anchor = options.anchor; } } } const result = await RNShare.share(shareContent, shareOptions); if (result.action === RNShare.sharedAction) { onShareSuccess?.(result.activityType); } else if (result.action === RNShare.dismissedAction) { onShareDismiss?.(); } } catch (error: any) { const shareError = error instanceof Error ? error : new Error(String(error)); onShareError?.(shareError); // More user-friendly error messages const errorMessage = getShareErrorMessage(shareError); Alert.alert('Share Error', errorMessage); } }, [ content, options, isContentValid, fallbackMessage, onShareStart, onShareSuccess, onShareError, onShareDismiss, ]); const isButtonDisabled = disabled || loading || !isContentValid; const getButtonTextStyle = (): TextStyle => { const baseTextStyle: TextStyle = { fontSize: FONT_SIZE, fontWeight: '500', }; switch (variant) { case 'destructive': return { ...baseTextStyle, color: destructiveForegroundColor }; case 'success': return { ...baseTextStyle, color: destructiveForegroundColor }; case 'outline': return { ...baseTextStyle, color: primaryColor }; case 'secondary': return { ...baseTextStyle, color: secondaryForegroundColor }; case 'ghost': return { ...baseTextStyle, color: primaryColor }; case 'link': return { ...baseTextStyle, color: primaryColor, textDecorationLine: 'underline', }; default: return { ...baseTextStyle, color: primaryForegroundColor }; } }; // Create button content with proper layout const buttonContent = () => { if (!showIcon || loading) { return children; } if (!children) { return <ShareIcon size={iconSize} color={getButtonTextStyle().color} />; } // Handle string children properly with correct styling const textContent = typeof children === 'string' ? ( <Text style={getButtonTextStyle()}>{children}</Text> ) : ( children ); return ( <View style={{ flexDirection: 'row', alignItems: 'center' }}> <ShareIcon size={iconSize} color={getButtonTextStyle().color} style={{ marginRight: 8 }} /> {textContent} </View> ); }; return ( <Button onPress={handleShare} variant={variant} size={size} disabled={isButtonDisabled} loading={loading} testID={testID} > {buttonContent()} </Button> ); } // Utility function to validate URLs function isValidUrl(url: string): boolean { try { new URL(url); return true; } catch { // Try with protocol if missing try { new URL(`https://${url}`); return true; } catch { return false; } } } // Utility function to provide user-friendly error messages function getShareErrorMessage(error: Error): string { const message = error.message.toLowerCase(); if (message.includes('cancel') || message.includes('dismiss')) { return 'Share was cancelled'; } if (message.includes('network') || message.includes('connection')) { return 'Network error occurred while sharing'; } if (message.includes('permission')) { return 'Permission denied for sharing'; } if (message.includes('not supported')) { return 'Sharing is not supported on this device'; } return 'An error occurred while sharing. Please try again.'; } // Hook for easier usage with common share scenarios export function useShare() { const shareText = useCallback( (text: string, options?: ShareButtonOptions) => { return RNShare.share({ message: text }, options); }, [] ); const shareUrl = useCallback( (url: string, message?: string, options?: ShareButtonOptions) => { return RNShare.share({ url, message }, options); }, [] ); const shareContent = useCallback( (content: ShareContent, options?: ShareButtonOptions) => { const shareData: any = {}; if (content.message) shareData.message = content.message; if (content.url) shareData.url = content.url; // `title` is Android-facing, `subject` is iOS-facing and belongs on // options (not content) — see RN's Share API docs. if (Platform.OS === 'android' && content.title) { shareData.title = content.title; } const shareOptions: ShareButtonOptions & Pick<ShareOptions, 'subject'> = { ...options, }; if (Platform.OS === 'ios' && content.subject) { shareOptions.subject = content.subject; } return RNShare.share(shareData, shareOptions); }, [] ); return { shareText, shareUrl, shareContent, }; } ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { ShareButton, useShare } from '@/components/ui/share'; ``` ```tsx <ShareButton content={{ message: 'Check out this amazing app!', url: 'https://example.com', }} > Share </ShareButton> ``` ## Examples #### Default **Example:** A basic share button with text and URL sharing ```tsx // components/demo/share/share-demo.tsx import { ShareButton } from '@/components/ui/share'; import React from 'react'; export function ShareDemo() { return ( <ShareButton content={{ message: 'Check out this amazing app!', url: 'https://example.com', title: 'Amazing App', }} onShareSuccess={(activityType) => { console.log('Shared successfully:', activityType); }} onShareError={(error) => { console.error('Share failed:', error); }} > Share </ShareButton> ); } ``` #### Share Variants **Example:** Share buttons with different visual variants ```tsx // components/demo/share/share-variants.tsx import { ShareButton } from '@/components/ui/share'; import { View } from '@/components/ui/view'; import React from 'react'; export function ShareVariants() { const shareContent = { message: 'Check out this amazing content!', url: 'https://example.com', }; return ( <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 12 }}> <ShareButton content={shareContent} variant='default'> Default </ShareButton> <ShareButton content={shareContent} variant='secondary'> Secondary </ShareButton> <ShareButton content={shareContent} variant='outline'> Outline </ShareButton> <ShareButton content={shareContent} variant='ghost'> Ghost </ShareButton> <ShareButton content={shareContent} variant='link'> Link </ShareButton> <ShareButton content={shareContent} variant='destructive'> Destructive </ShareButton> </View> ); } ``` #### Share Sizes **Example:** Share buttons in different sizes ```tsx // components/demo/share/share-sizes.tsx import { ShareButton } from '@/components/ui/share'; import { View } from '@/components/ui/view'; import React from 'react'; export function ShareSizes() { const shareContent = { message: 'Check out this amazing content!', url: 'https://example.com', }; return ( <View style={{ gap: 12, alignItems: 'center' }}> <ShareButton content={shareContent} size='sm'> Small </ShareButton> <ShareButton content={shareContent} size='default'> Default </ShareButton> <ShareButton content={shareContent} size='lg'> Large </ShareButton> <ShareButton content={shareContent} size='icon' iconSize={20} /> </View> ); } ``` #### URL Only **Example:** Share button for sharing URLs without additional text ```tsx // components/demo/share/share-url-only.tsx import { ShareButton } from '@/components/ui/share'; import { View } from '@/components/ui/view'; import React from 'react'; export function ShareUrlOnly() { return ( <View style={{ gap: 12 }}> <ShareButton content={{ url: 'https://github.com' }} variant='outline'> Share GitHub </ShareButton> <ShareButton content={{ url: 'https://reactnative.dev' }} variant='secondary' > Share React Native Docs </ShareButton> <ShareButton content={{ url: 'https://expo.dev' }} variant='ghost'> Share Expo </ShareButton> </View> ); } ``` #### Custom Content **Example:** Share button with custom title, subject, and content ```tsx // components/demo/share/share-custom-content.tsx import { ShareButton } from '@/components/ui/share'; import { View } from '@/components/ui/view'; import React from 'react'; export function ShareCustomContent() { return ( <View style={{ gap: 12 }}> {/* Rich content with title and subject */} <ShareButton content={{ message: 'I found this amazing article about React Native development. You should definitely check it out!', url: 'https://reactnative.dev/blog', title: 'React Native Blog', subject: 'Great React Native Article', }} options={{ dialogTitle: 'Share this article', }} > Share Article </ShareButton> {/* App promotion */} <ShareButton content={{ message: '🚀 Just discovered this incredible mobile app! The UI is amazing and it works perfectly on both iOS and Android. Download it now!', url: 'https://apps.apple.com/app/example', title: 'Amazing Mobile App', subject: 'You need to try this app!', }} variant='secondary' > Share App </ShareButton> {/* Event invitation */} <ShareButton content={{ message: "🎉 You're invited to our tech meetup! Join us for an evening of networking, learning, and great discussions about mobile development.", url: 'https://meetup.com/event/123', title: 'Tech Meetup Invitation', subject: 'Join us at the Tech Meetup!', }} variant='outline' > Share Event </ShareButton> </View> ); } ``` #### Icon Only **Example:** Compact share button with icon only ```tsx // components/demo/share/share-icon-only.tsx import { ShareButton } from '@/components/ui/share'; import { View } from '@/components/ui/view'; import React from 'react'; export function ShareIconOnly() { const shareContent = { message: 'Check out this amazing content!', url: 'https://example.com', }; return ( <View style={{ flexDirection: 'row', gap: 8 }}> <ShareButton content={shareContent} size='icon' variant='default' iconSize={18} /> <ShareButton content={shareContent} size='icon' variant='secondary' iconSize={18} /> <ShareButton content={shareContent} size='icon' variant='outline' iconSize={18} /> <ShareButton content={shareContent} size='icon' variant='ghost' iconSize={20} /> </View> ); } ``` #### With Callbacks **Example:** Share button with success, error, and dismiss callbacks ```tsx // components/demo/share/share-callbacks.tsx import { ShareButton } from '@/components/ui/share'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function ShareCallbacks() { const [status, setStatus] = useState<string>('Ready to share'); const [isLoading, setIsLoading] = useState(false); const handleShareStart = () => { setStatus('Starting share...'); setIsLoading(true); }; const handleShareSuccess = (activityType?: string | null) => { setStatus( `Shared successfully${activityType ? ` via ${activityType}` : ''}!` ); setIsLoading(false); // Reset status after 3 seconds setTimeout(() => setStatus('Ready to share'), 3000); }; const handleShareError = (error: Error) => { setStatus(`Share failed: ${error.message}`); setIsLoading(false); // Reset status after 3 seconds setTimeout(() => setStatus('Ready to share'), 3000); }; const handleShareDismiss = () => { setStatus('Share cancelled'); setIsLoading(false); // Reset status after 2 seconds setTimeout(() => setStatus('Ready to share'), 2000); }; return ( <View style={{ gap: 16 }}> <Text style={{ fontWeight: '500' }}>Status: {status}</Text> <ShareButton content={{ message: 'Check out this awesome React Native component library!', url: 'https://github.com/ahmedbna/ui', title: 'UI Component Library', }} loading={isLoading} onShareStart={handleShareStart} onShareSuccess={handleShareSuccess} onShareError={handleShareError} onShareDismiss={handleShareDismiss} > Share with Callbacks </ShareButton> </View> ); } ``` #### Hook Usage **Example:** Using the useShare hook for programmatic sharing ```tsx // components/demo/share/share-hook.tsx import { Button } from '@/components/ui/button'; import { useShare } from '@/components/ui/share'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function ShareHook() { const { shareText, shareUrl, shareContent } = useShare(); const [status, setStatus] = useState<string>('Choose a sharing method'); const handleShareText = async () => { try { setStatus('Sharing text...'); await shareText( 'Hello from the useShare hook! This is just a plain text message.' ); setStatus('Text shared successfully!'); } catch (error) { setStatus(`Failed to share text: ${(error as Error).message}`); } }; const handleShareUrl = async () => { try { setStatus('Sharing URL...'); await shareUrl( 'https://reactnative.dev', 'Check out the official React Native documentation!' ); setStatus('URL shared successfully!'); } catch (error) { setStatus(`Failed to share URL: ${(error as Error).message}`); } }; const handleShareContent = async () => { try { setStatus('Sharing content...'); await shareContent({ message: '🚀 Just built an amazing React Native app with this component library!', url: 'https://github.com/ahmedbna/ui', title: 'Amazing UI Library', subject: 'Check out this UI library', }); setStatus('Content shared successfully!'); } catch (error) { setStatus(`Failed to share content: ${(error as Error).message}`); } }; return ( <View style={{ gap: 16 }}> <Text style={{ fontWeight: '500', textAlign: 'center' }}>{status}</Text> <View style={{ gap: 8 }}> <Button onPress={handleShareText} variant='outline'> Share Text Only </Button> <Button onPress={handleShareUrl} variant='secondary'> Share URL with Message </Button> <Button onPress={handleShareContent} variant='default'> Share Rich Content </Button> </View> </View> ); } ``` ## API Reference ### ShareButton The main share button component that handles native sharing functionality. | Prop | Type | Default | Description | | ----------------- | ------------------------------------- | ----------- | ------------------------------------------------- | | `content` | `ShareContent` | - | **Required.** The content to be shared. | | `options` | `ShareButtonOptions` | - | Platform-specific sharing options. | | `children` | `ReactNode` | - | Button content. Shows share icon if not provided. | | `variant` | `ButtonVariant` | `'default'` | Button visual variant. | | `size` | `'default' \| 'sm' \| 'lg' \| 'icon'` | `'default'` | Button size. | | `disabled` | `boolean` | `false` | Whether the button is disabled. | | `loading` | `boolean` | `false` | Whether the button is in loading state. | | `onShareStart` | `() => void` | - | Callback when sharing starts. | | `onShareSuccess` | `(activityType?: string) => void` | - | Callback when sharing succeeds. | | `onShareError` | `(error: Error) => void` | - | Callback when sharing fails. | | `onShareDismiss` | `() => void` | - | Callback when share dialog is dismissed. | | `showIcon` | `boolean` | `true` | Whether to show the share icon. | | `iconSize` | `number` | `18` | Size of the share icon. | | `validateContent` | `boolean` | `true` | Whether to validate content before sharing. | | `testID` | `string` | - | Test identifier for testing. | ### ShareContent The content object that defines what will be shared. | Property | Type | Description | | --------- | -------- | ---------------------------------------------- | | `message` | `string` | The text message to share. | | `url` | `string` | The URL to share. | | `title` | `string` | The title for the shared content (iOS only). | | `subject` | `string` | The subject line for email sharing (iOS only). | ### ShareButtonOptions Platform-specific options for customizing the share dialog. | Property | Type | Description | | ----------------------- | ---------- | -------------------------------------------------- | | `dialogTitle` | `string` | Title for the share dialog (Android only). | | `excludedActivityTypes` | `string[]` | Activity types to exclude from sharing (iOS only). | | `tintColor` | `string` | Tint color for the share sheet (iOS only). | | `anchor` | `number` | Anchor point for iPad popover (iOS only). | ### useShare Hook A hook that provides programmatic sharing functions without UI components. #### Returns | Function | Type | Description | | -------------- | ------------------------------------------------------------------------------- | ---------------------------------- | | `shareText` | `(text: string, options?: ShareButtonOptions) => Promise<any>` | Share plain text. | | `shareUrl` | `(url: string, message?: string, options?: ShareButtonOptions) => Promise<any>` | Share a URL with optional message. | | `shareContent` | `(content: ShareContent, options?: ShareButtonOptions) => Promise<any>` | Share complex content object. | ## Platform Differences ### iOS - Supports `title` and `subject` properties in share content - Supports `excludedActivityTypes`, `tintColor`, and `anchor` options - Share sheet appears as a modal from the bottom - On iPad, can be anchored to a specific point ### Android - Only supports `message` and `url` in share content - Supports `dialogTitle` option for customizing dialog title - Share dialog appears as a bottom sheet with available apps ## Error Handling The ShareButton component includes comprehensive error handling: - **Invalid Content**: Validates that either `message` or `url` is provided - **Network Errors**: Handles network-related sharing failures - **Permission Errors**: Handles cases where sharing permissions are denied - **Unsupported Platform**: Handles devices that don't support sharing Error messages are user-friendly and provide actionable feedback. ## Accessibility The Share component is built with accessibility in mind: - Uses semantic button structure for screen readers - Provides appropriate ARIA labels and roles - Supports dynamic text sizing - Maintains proper focus management - Includes loading states for better user feedback ## Best Practices 1. **Content Validation**: Always provide either a `message` or `url` in your share content 2. **Error Handling**: Implement `onShareError` callback to handle sharing failures gracefully 3. **Loading States**: Use the `loading` prop during async operations before sharing 4. **Platform Testing**: Test sharing functionality on both iOS and Android devices 5. **Fallback Options**: Consider providing alternative sharing methods if native sharing fails <!-- ---------------------------------------------------------------------- --> # Sheet > A modal component that slides in from the side of the screen, commonly used for navigation menus, filters, and detail views. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/sheet - Markdown: https://ui.ahmedbna.com/docs/components/sheet.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/sheet.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/sheet.json - Install: `npx bna-ui add sheet` - npm dependencies: `expo-haptics`, `lucide-react-native`, `react-native-reanimated`, `react-native-safe-area-context`, `react-native-svg`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `useHaptics`, `text`, `view`, `icon`, `spinner`, `button` - Preview recording: https://demo.ahmedbna.com/0268-sheet-demo.MP4 --- **Example:** A basic sheet that slides in from the right side ```tsx // components/demo/sheet/sheet-demo.tsx import { Button } from '@/components/ui/button'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger, } from '@/components/ui/sheet'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function SheetDemo() { const [open, setOpen] = useState(false); return ( <Sheet open={open} onOpenChange={setOpen}> <SheetTrigger>Open Sheet</SheetTrigger> <SheetContent> <SheetHeader> <SheetTitle>Welcome to the Sheet</SheetTitle> <SheetDescription> This is a basic sheet component that slides in from the right side of the screen. </SheetDescription> </SheetHeader> <View style={{ padding: 24, gap: 16 }}> <Text> This sheet can contain any content you need. It's perfect for navigation menus, forms, settings, or detailed information. </Text> <Button onPress={() => setOpen(false)}>Close Sheet</Button> </View> </SheetContent> </Sheet> ); } ``` ## Installation ### CLI ```bash npx bna-ui add sheet ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install lucide-react-native ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/sheet.tsx import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { BORDER_RADIUS, FONT_SIZE } from '@/theme/globals'; import { X } from 'lucide-react-native'; import React, { useEffect } from 'react'; import { Modal, Platform, Pressable, StyleSheet, TouchableOpacity, useWindowDimensions, ViewStyle, } from 'react-native'; import Animated, { Easing, interpolate, runOnJS, useAnimatedStyle, useSharedValue, withTiming, } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; type SheetSide = 'left' | 'right'; interface SheetProps { open: boolean; onOpenChange: (open: boolean) => void; side?: SheetSide; children: React.ReactNode; } interface SheetContentProps { children: React.ReactNode; style?: ViewStyle; } interface SheetHeaderProps { children: React.ReactNode; style?: ViewStyle; } interface SheetTitleProps { children: React.ReactNode; } interface SheetDescriptionProps { children: React.ReactNode; } interface SheetTriggerProps { children: React.ReactNode; asChild?: boolean; } interface SheetContextValue { open: boolean; onOpenChange: (open: boolean) => void; side: SheetSide; } const SheetContext = React.createContext<SheetContextValue | null>(null); const useSheet = () => { const context = React.useContext(SheetContext); if (!context) { throw new Error('Sheet components must be used within a Sheet'); } return context; }; export function Sheet({ open, onOpenChange, side = 'right', children, }: SheetProps) { return ( <SheetContext.Provider value={{ open, onOpenChange, side }}> {children} </SheetContext.Provider> ); } export function SheetTrigger({ children, asChild }: SheetTriggerProps) { const context = React.useContext(SheetContext); const handlePress = () => { if (context) { context.onOpenChange(true); } }; if (asChild && React.isValidElement(children)) { return React.cloneElement(children as React.ReactElement<any>, { onPress: handlePress, }); } return <Button onPress={handlePress}>{children}</Button>; } export function SheetContent({ children, style }: SheetContentProps) { const { open, onOpenChange, side } = useSheet(); const { width: screenWidth } = useWindowDimensions(); const insets = useSafeAreaInsets(); const sheetWidth = Math.min(screenWidth * 0.8, 400); const [isVisible, setIsVisible] = React.useState(open); const backgroundColor = useColor('background'); const borderColor = useColor('border'); const iconColor = useColor('text'); // Animation values using Reanimated's useSharedValue const initialPosition = side === 'left' ? -sheetWidth : sheetWidth; const translateX = useSharedValue(initialPosition); const overlayOpacity = useSharedValue(0); // Effect to handle the animation based on the `open` prop useEffect(() => { // Reset position if side changes while closed if (open && !isVisible) { translateX.value = side === 'left' ? -sheetWidth : sheetWidth; } if (open) { setIsVisible(true); // Mount the modal // Animate in translateX.value = withTiming(0, { duration: 300, easing: Easing.out(Easing.quad), }); overlayOpacity.value = withTiming(1, { duration: 300 }); } else if (isVisible) { // Animate out, then hide modal in the callback translateX.value = withTiming( initialPosition, { duration: 250 }, (finished) => { if (finished) { // Use runOnJS to update React state from the UI thread runOnJS(setIsVisible)(false); } } ); overlayOpacity.value = withTiming(0, { duration: 250 }); } }, [open, side, sheetWidth]); // Rerun if these change // Animated style for the sheet content const animatedSheetStyle = useAnimatedStyle(() => { return { transform: [{ translateX: translateX.value }], }; }); // Animated style for the overlay const animatedOverlayStyle = useAnimatedStyle(() => { return { opacity: interpolate(overlayOpacity.value, [0, 1], [0, 0.3]), }; }); const handleClose = () => { onOpenChange(false); }; if (!isVisible) { return null; } return ( <Modal visible={isVisible} transparent={true} animationType='none' onRequestClose={handleClose} statusBarTranslucent={true} > <View style={styles.modalContainer}> {/* Semi-transparent overlay */} <Animated.View style={[styles.overlay, animatedOverlayStyle]}> <Pressable style={styles.overlayPressable} onPress={handleClose} /> </Animated.View> {/* Sheet */} <Animated.View style={[ styles.sheet, { borderRadius: BORDER_RADIUS, backgroundColor, borderColor, width: sheetWidth, [side]: 0, }, animatedSheetStyle, // Apply the animated style style, ]} accessibilityViewIsModal > {/* Close button */} <TouchableOpacity style={[ styles.closeButton, { backgroundColor: backgroundColor, top: insets.top + 10, [side === 'left' ? 'right' : 'left']: 16, }, ]} onPress={handleClose} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} accessibilityRole='button' accessibilityLabel='Close' > <X size={20} color={iconColor} /> </TouchableOpacity> {/* Content */} <View style={styles.contentContainer}>{children}</View> </Animated.View> </View> </Modal> ); } // Unchanged components below export function SheetHeader({ children, style }: SheetHeaderProps) { const insets = useSafeAreaInsets(); return ( <View style={[styles.header, { paddingTop: insets.top + 50 }, style]}> {children} </View> ); } export function SheetTitle({ children }: SheetTitleProps) { return ( <Text variant='title' style={styles.title}> {children} </Text> ); } export function SheetDescription({ children }: SheetDescriptionProps) { const mutedColor = useColor('textMuted'); return ( <Text style={[styles.description, { color: mutedColor }]}>{children}</Text> ); } const styles = StyleSheet.create({ modalContainer: { flex: 1, }, overlay: { ...StyleSheet.absoluteFill, backgroundColor: 'rgba(0, 0, 0, 1)', // Opacity is controlled by animation }, overlayPressable: { flex: 1, }, sheet: { position: 'absolute', top: 0, bottom: 0, borderLeftWidth: 1, borderRightWidth: 1, ...Platform.select({ ios: { shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.25, shadowRadius: 8, }, android: { elevation: 10, }, }), }, closeButton: { position: 'absolute', zIndex: 1, borderRadius: 999, // Make it circular width: 32, height: 32, alignItems: 'center', justifyContent: 'center', }, contentContainer: { flex: 1, }, header: { paddingHorizontal: 24, paddingBottom: 16, }, title: { marginBottom: 8, }, description: { fontSize: FONT_SIZE, lineHeight: 20, }, }); ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger, } from '@/components/ui/sheet'; ``` ```tsx <Sheet> <SheetTrigger> <Button>Open Sheet</Button> </SheetTrigger> <SheetContent> <SheetHeader> <SheetTitle>Sheet Title</SheetTitle> <SheetDescription> This is a description of the sheet content. </SheetDescription> </SheetHeader> {/* Your content here */} </SheetContent> </Sheet> ``` ## Examples #### Default **Example:** A basic sheet that slides in from the right side ```tsx // components/demo/sheet/sheet-demo.tsx import { Button } from '@/components/ui/button'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger, } from '@/components/ui/sheet'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function SheetDemo() { const [open, setOpen] = useState(false); return ( <Sheet open={open} onOpenChange={setOpen}> <SheetTrigger>Open Sheet</SheetTrigger> <SheetContent> <SheetHeader> <SheetTitle>Welcome to the Sheet</SheetTitle> <SheetDescription> This is a basic sheet component that slides in from the right side of the screen. </SheetDescription> </SheetHeader> <View style={{ padding: 24, gap: 16 }}> <Text> This sheet can contain any content you need. It's perfect for navigation menus, forms, settings, or detailed information. </Text> <Button onPress={() => setOpen(false)}>Close Sheet</Button> </View> </SheetContent> </Sheet> ); } ``` #### Left Side **Example:** A sheet that slides in from the left side ```tsx // components/demo/sheet/sheet-left.tsx import { Button } from '@/components/ui/button'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger, } from '@/components/ui/sheet'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function SheetLeft() { const [open, setOpen] = useState(false); return ( <Sheet open={open} onOpenChange={setOpen} side='left'> <SheetTrigger asChild> <Button>Open Left Sheet</Button> </SheetTrigger> <SheetContent> <SheetHeader> <SheetTitle>Left Side Sheet</SheetTitle> <SheetDescription> This sheet slides in from the left side of the screen. </SheetDescription> </SheetHeader> <View style={{ padding: 24, gap: 16 }}> <Text> Left-side sheets are commonly used for navigation menus and primary actions that need to be easily accessible. </Text> <Button onPress={() => setOpen(false)}>Close Sheet</Button> </View> </SheetContent> </Sheet> ); } ``` #### Navigation Menu **Example:** A sheet used as a navigation menu with links ```tsx // components/demo/sheet/sheet-navigation.tsx import { Button } from '@/components/ui/button'; import { Icon } from '@/components/ui/icon'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger, } from '@/components/ui/sheet'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { Bell, Home, Mail, Search, Settings, User } from 'lucide-react-native'; import React, { useState } from 'react'; import { StyleSheet, TouchableOpacity } from 'react-native'; export function SheetNavigation() { const [open, setOpen] = useState(false); const [activeItem, setActiveItem] = useState('home'); const textColor = useColor('text'); const mutedColor = useColor('textMuted'); const borderColor = useColor('border'); const navigationItems = [ { id: 'home', label: 'Home', icon: Home }, { id: 'profile', label: 'Profile', icon: User }, { id: 'messages', label: 'Messages', icon: Mail }, { id: 'search', label: 'Search', icon: Search }, { id: 'notifications', label: 'Notifications', icon: Bell }, { id: 'settings', label: 'Settings', icon: Settings }, ]; const handleItemPress = (itemId: string) => { setActiveItem(itemId); setOpen(false); }; return ( <Sheet open={open} onOpenChange={setOpen} side='left'> <SheetTrigger asChild> <Button>Open Navigation</Button> </SheetTrigger> <SheetContent> <SheetHeader> <SheetTitle>Navigation Menu</SheetTitle> <SheetDescription> Navigate to different sections of the app. </SheetDescription> </SheetHeader> <View style={styles.navigationContainer}> {navigationItems.map((item) => { const name = item.icon; const isActive = activeItem === item.id; return ( <TouchableOpacity key={item.id} style={[ styles.navigationItem, { backgroundColor: isActive ? `${textColor}10` : 'transparent', borderColor, }, ]} onPress={() => handleItemPress(item.id)} > <Icon name={name} size={20} color={isActive ? textColor : mutedColor} /> <Text style={[ styles.navigationText, { color: isActive ? textColor : mutedColor }, ]} > {item.label} </Text> </TouchableOpacity> ); })} </View> </SheetContent> </Sheet> ); } const styles = StyleSheet.create({ navigationContainer: { padding: 16, gap: 8, }, navigationItem: { flexDirection: 'row', alignItems: 'center', gap: 12, padding: 12, borderRadius: 8, borderWidth: 1, borderColor: 'transparent', }, navigationText: { fontSize: 16, fontWeight: '500', }, }); ``` #### Form Sheet **Example:** A sheet containing a form with input fields ```tsx // components/demo/sheet/sheet-form.tsx import { Button } from '@/components/ui/button'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger, } from '@/components/ui/sheet'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import React, { useState } from 'react'; import { Alert, StyleSheet, TextInput } from 'react-native'; export function SheetForm() { const [open, setOpen] = useState(false); const [formData, setFormData] = useState({ name: '', email: '', message: '', }); const textColor = useColor('text'); const backgroundColor = useColor('background'); const borderColor = useColor('border'); const mutedColor = useColor('textMuted'); const handleSubmit = () => { if (!formData.name || !formData.email || !formData.message) { Alert.alert('Error', 'Please fill in all fields'); return; } Alert.alert('Success', 'Form submitted successfully!'); setFormData({ name: '', email: '', message: '' }); setOpen(false); }; const handleReset = () => { setFormData({ name: '', email: '', message: '' }); }; return ( <Sheet open={open} onOpenChange={setOpen}> <SheetTrigger asChild> <Button>Open Contact Form</Button> </SheetTrigger> <SheetContent> <SheetHeader> <SheetTitle>Contact Us</SheetTitle> <SheetDescription> Fill out the form below and we'll get back to you soon. </SheetDescription> </SheetHeader> <View style={styles.formContainer}> <View style={styles.fieldContainer}> <Text style={[styles.label, { color: textColor }]}>Name</Text> <TextInput style={[ styles.input, { borderColor, backgroundColor, color: textColor, }, ]} value={formData.name} onChangeText={(text) => setFormData((prev) => ({ ...prev, name: text })) } placeholder='Enter your name' placeholderTextColor={mutedColor} /> </View> <View style={styles.fieldContainer}> <Text style={[styles.label, { color: textColor }]}>Email</Text> <TextInput style={[ styles.input, { borderColor, backgroundColor, color: textColor, }, ]} value={formData.email} onChangeText={(text) => setFormData((prev) => ({ ...prev, email: text })) } placeholder='Enter your email' placeholderTextColor={mutedColor} keyboardType='email-address' autoCapitalize='none' /> </View> <View style={styles.fieldContainer}> <Text style={[styles.label, { color: textColor }]}>Message</Text> <TextInput style={[ styles.input, styles.textArea, { borderColor, backgroundColor, color: textColor, }, ]} value={formData.message} onChangeText={(text) => setFormData((prev) => ({ ...prev, message: text })) } placeholder='Enter your message' placeholderTextColor={mutedColor} multiline numberOfLines={4} textAlignVertical='top' /> </View> <View style={styles.buttonContainer}> <Button style={styles.button} onPress={handleSubmit}> Submit </Button> <Button variant='outline' style={styles.button} onPress={handleReset} > Reset </Button> </View> </View> </SheetContent> </Sheet> ); } const styles = StyleSheet.create({ formContainer: { padding: 24, gap: 20, }, fieldContainer: { gap: 8, }, label: { fontSize: 16, fontWeight: '500', }, input: { borderWidth: 1, borderRadius: 8, padding: 12, fontSize: 16, }, textArea: { height: 100, }, buttonContainer: { flexDirection: 'row', gap: 12, marginTop: 12, }, button: { flex: 1, }, }); ``` #### Filter Sheet **Example:** A sheet used for filtering options ```tsx // components/demo/sheet/sheet-filter.tsx import { Button } from '@/components/ui/button'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger, } from '@/components/ui/sheet'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { Filter } from 'lucide-react-native'; import React, { useState } from 'react'; import { StyleSheet, TouchableOpacity } from 'react-native'; export function SheetFilter() { const [open, setOpen] = useState(false); const [filters, setFilters] = useState({ category: 'all', price: 'all', rating: 'all', brand: 'all', }); const textColor = useColor('text'); const mutedColor = useColor('textMuted'); const borderColor = useColor('border'); const filterOptions = { category: [ { value: 'all', label: 'All Categories' }, { value: 'electronics', label: 'Electronics' }, { value: 'clothing', label: 'Clothing' }, { value: 'books', label: 'Books' }, { value: 'home', label: 'Home & Garden' }, ], price: [ { value: 'all', label: 'Any Price' }, { value: 'under-25', label: 'Under $25' }, { value: '25-50', label: '$25 - $50' }, { value: '50-100', label: '$50 - $100' }, { value: 'over-100', label: 'Over $100' }, ], rating: [ { value: 'all', label: 'Any Rating' }, { value: '4-plus', label: '4+ Stars' }, { value: '3-plus', label: '3+ Stars' }, { value: '2-plus', label: '2+ Stars' }, ], brand: [ { value: 'all', label: 'All Brands' }, { value: 'apple', label: 'Apple' }, { value: 'samsung', label: 'Samsung' }, { value: 'nike', label: 'Nike' }, { value: 'adidas', label: 'Adidas' }, ], }; const handleFilterChange = ( filterType: keyof typeof filters, value: string ) => { setFilters((prev) => ({ ...prev, [filterType]: value })); }; const handleApplyFilters = () => { // Apply filters logic here console.log('Applied filters:', filters); setOpen(false); }; const handleClearFilters = () => { setFilters({ category: 'all', price: 'all', rating: 'all', brand: 'all', }); }; const renderFilterSection = ( title: string, filterType: keyof typeof filters, options: { value: string; label: string }[] ) => ( <View style={styles.filterSection}> <Text style={[styles.sectionTitle, { color: textColor }]}>{title}</Text> <View style={styles.optionsContainer}> {options.map((option) => ( <TouchableOpacity key={option.value} style={[ styles.option, { borderColor, backgroundColor: filters[filterType] === option.value ? `${textColor}10` : 'transparent', }, ]} onPress={() => handleFilterChange(filterType, option.value)} > <Text style={[ styles.optionText, { color: filters[filterType] === option.value ? textColor : mutedColor, }, ]} > {option.label} </Text> </TouchableOpacity> ))} </View> </View> ); return ( <Sheet open={open} onOpenChange={setOpen}> <SheetTrigger asChild> <Button icon={Filter}>Filter</Button> </SheetTrigger> <SheetContent> <SheetHeader> <SheetTitle>Filter Products</SheetTitle> <SheetDescription> Refine your search results using the filters below. </SheetDescription> </SheetHeader> <View style={styles.filterContainer}> {renderFilterSection('Category', 'category', filterOptions.category)} {renderFilterSection('Price Range', 'price', filterOptions.price)} {renderFilterSection('Rating', 'rating', filterOptions.rating)} {renderFilterSection('Brand', 'brand', filterOptions.brand)} <View style={styles.buttonContainer}> <Button style={styles.button} onPress={handleApplyFilters}> Apply Filters </Button> <Button variant='outline' style={styles.button} onPress={handleClearFilters} > Clear All </Button> </View> </View> </SheetContent> </Sheet> ); } const styles = StyleSheet.create({ filterContainer: { padding: 16, gap: 24, }, filterSection: { gap: 12, }, sectionTitle: { fontSize: 18, fontWeight: '600', }, optionsContainer: { gap: 8, }, option: { padding: 12, borderRadius: 8, borderWidth: 1, }, optionText: { fontSize: 16, }, buttonContainer: { flexDirection: 'row', gap: 12, marginTop: 12, }, button: { flex: 1, }, }); ``` ## API Reference ### Sheet The root component that manages the sheet state and provides context to child components. | Prop | Type | Default | Description | | -------------- | ------------------------- | --------- | --------------------------------------------- | | `open` | `boolean` | - | Controls whether the sheet is open or closed. | | `onOpenChange` | `(open: boolean) => void` | - | Callback fired when the sheet state changes. | | `side` | `'left' \| 'right'` | `'right'` | The side from which the sheet slides in. | | `children` | `ReactNode` | - | The sheet trigger and content components. | ### SheetTrigger The trigger component that opens the sheet when pressed. | Prop | Type | Default | Description | | ---------- | ----------- | ------- | ----------------------------------------------- | | `children` | `ReactNode` | - | The trigger content (usually a button or text). | | `asChild` | `boolean` | `false` | Whether to render as a child component. | ### SheetContent The main content container that slides in from the specified side. | Prop | Type | Description | | ---------- | ----------- | ---------------------------------------------------- | | `children` | `ReactNode` | The content to display inside the sheet. | | `style` | `ViewStyle` | Additional styles to apply to the content container. | ### SheetHeader A header component that provides consistent spacing and layout for the sheet title and description. | Prop | Type | Description | | ---------- | ----------- | ------------------------------------------- | | `children` | `ReactNode` | The header content (title and description). | | `style` | `ViewStyle` | Additional styles to apply to the header. | ### SheetTitle The title component for the sheet header. | Prop | Type | Description | | ---------- | ----------- | -------------------------- | | `children` | `ReactNode` | The title text or content. | ### SheetDescription The description component for the sheet header. | Prop | Type | Description | | ---------- | ----------- | -------------------------------- | | `children` | `ReactNode` | The description text or content. | ## Accessibility The Sheet component is built with accessibility in mind: - Uses native Modal component for proper focus management - Includes proper ARIA attributes for screen readers - Supports keyboard navigation and dismissal - Close button is positioned for easy access - Overlay can be pressed to dismiss the sheet - Proper hit areas for touch targets ## Animation The Sheet component includes smooth animations: - Slides in from the specified side (left or right) - Fades in the overlay backdrop - Animates out when dismissed - Uses native animations for optimal performance ## Notes - Maximum width is set to 80% of screen width or 400px, whichever is smaller - Includes platform-specific shadow styling for iOS and Android - Close button position adapts based on the sheet side <!-- ---------------------------------------------------------------------- --> # Skeleton > A placeholder component to show a loading state while content is being fetched. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/skeleton - Markdown: https://ui.ahmedbna.com/docs/components/skeleton.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/skeleton.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/skeleton.json - Install: `npx bna-ui add skeleton` - npm dependencies: `react-native-reanimated` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals` - Preview recording: https://demo.ahmedbna.com/0273-skeleton-demo.MP4 --- **Example:** A basic skeleton loader with pulsing animation ```tsx // components/demo/skeleton/skeleton-demo.tsx import { Skeleton } from '@/components/ui/skeleton'; import React from 'react'; export function SkeletonDemo() { return <Skeleton width={200} height={20} />; } ``` ## Installation ### CLI ```bash npx bna-ui add skeleton ``` ### Manual **1.** Copy and paste the following code into your project. ```tsx // components/ui/skeleton.tsx import { useColor } from '@/hooks/useColor'; import { BORDER_RADIUS, CORNERS } from '@/theme/globals'; import React, { useEffect } from 'react'; import { ViewStyle } from 'react-native'; import Animated, { Easing, useSharedValue, useAnimatedStyle, withTiming, withRepeat, } from 'react-native-reanimated'; interface SkeletonProps { width?: number | string; height?: number; style?: ViewStyle; variant?: 'default' | 'rounded'; } export const Skeleton = React.memo(function Skeleton({ width = '100%', height = 100, style, variant = 'default', }: SkeletonProps) { const mutedColor = useColor('muted'); // Start the opacity at its lowest point const opacity = useSharedValue(0.5); const animatedStyle = useAnimatedStyle(() => { return { opacity: opacity.value, }; }); useEffect(() => { // We only define the animation going from 0.5 -> 1. // The `withRepeat` function will handle reversing it automatically. opacity.value = withRepeat( // Animate to an opacity of 1 withTiming(1, { duration: 1000, easing: Easing.inOut(Easing.quad), }), -1, // Loop infinitely true // Set to true to automatically reverse the animation (yoyo effect) ); }, []); // Use an empty dependency array as the shared value object is stable return ( <Animated.View accessibilityElementsHidden accessibilityLabel='Loading content' style={[ { width: width as any, height, backgroundColor: mutedColor, borderRadius: variant === 'default' ? CORNERS : BORDER_RADIUS, }, animatedStyle, style, ]} /> ); }); ``` **2.** Update the import paths to match your project setup. ## Usage ```tsx import { Skeleton } from '@/components/ui/skeleton'; ``` ```tsx <Skeleton width={200} height={20} /> ``` ## Examples #### Default **Example:** A basic skeleton loader with pulsing animation ```tsx // components/demo/skeleton/skeleton-demo.tsx import { Skeleton } from '@/components/ui/skeleton'; import React from 'react'; export function SkeletonDemo() { return <Skeleton width={200} height={20} />; } ``` #### Different Sizes **Example:** Skeletons in various sizes and dimensions ```tsx // components/demo/skeleton/skeleton-sizes.tsx import { Skeleton } from '@/components/ui/skeleton'; import { View } from '@/components/ui/view'; import React from 'react'; export function SkeletonSizes() { return ( <View style={{ gap: 12 }}> <Skeleton width={100} height={16} /> <Skeleton width={200} height={20} /> <Skeleton width={300} height={24} /> <Skeleton width='100%' height={32} /> </View> ); } ``` #### Card Layout **Example:** Skeleton placeholders arranged in a card layout ```tsx // components/demo/skeleton/skeleton-card.tsx import { Skeleton } from '@/components/ui/skeleton'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { BORDER_RADIUS } from '@/theme/globals'; import React from 'react'; export function SkeletonCard() { const card = useColor('card'); return ( <View style={{ padding: 16, borderRadius: BORDER_RADIUS, backgroundColor: card, gap: 12, }} > {/* Header */} <View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}> <Skeleton width={40} height={40} style={{ borderRadius: 20 }} /> <View style={{ flex: 1, gap: 4 }}> <Skeleton width='60%' height={16} /> <Skeleton width='40%' height={12} /> </View> </View> {/* Content */} <Skeleton width='100%' height={200} variant='rounded' /> {/* Footer */} <View style={{ gap: 8 }}> <Skeleton width='100%' height={16} /> <Skeleton width='80%' height={16} /> <Skeleton width='60%' height={16} /> </View> </View> ); } ``` #### Profile Layout **Example:** Skeleton layout mimicking a user profile ```tsx // components/demo/skeleton/skeleton-profile.tsx import { Skeleton } from '@/components/ui/skeleton'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { BORDER_RADIUS } from '@/theme/globals'; import React from 'react'; export function SkeletonProfile() { const card = useColor('card'); return ( <View style={{ alignItems: 'center', gap: 16, padding: 16, borderRadius: BORDER_RADIUS, backgroundColor: card, }} > {/* Profile Picture */} <Skeleton width={80} height={80} style={{ borderRadius: 40 }} /> {/* Name and Title */} <View style={{ alignItems: 'center', gap: 8 }}> <Skeleton width={150} height={20} /> <Skeleton width={100} height={16} /> </View> {/* Stats */} <View style={{ flexDirection: 'row', gap: 24 }}> <View style={{ alignItems: 'center', gap: 4 }}> <Skeleton width={30} height={18} /> <Skeleton width={50} height={14} /> </View> <View style={{ alignItems: 'center', gap: 4 }}> <Skeleton width={30} height={18} /> <Skeleton width={50} height={14} /> </View> <View style={{ alignItems: 'center', gap: 4 }}> <Skeleton width={30} height={18} /> <Skeleton width={50} height={14} /> </View> </View> {/* Bio */} <View style={{ gap: 8, width: '100%' }}> <Skeleton width='100%' height={16} /> <Skeleton width='90%' height={16} /> <Skeleton width='70%' height={16} /> </View> </View> ); } ``` #### List Items **Example:** Multiple skeleton items arranged in a list ```tsx // components/demo/skeleton/skeleton-list.tsx import { Skeleton } from '@/components/ui/skeleton'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { BORDER_RADIUS } from '@/theme/globals'; import React from 'react'; export function SkeletonList() { const card = useColor('card'); return ( <View style={{ gap: 16, padding: 16, borderRadius: BORDER_RADIUS, backgroundColor: card, }} > {Array.from({ length: 5 }, (_, i) => ( <View key={i} style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }} > <Skeleton width={50} height={50} style={{ borderRadius: 25 }} /> <View style={{ flex: 1, gap: 6 }}> <Skeleton width='70%' height={16} /> <Skeleton width='50%' height={14} /> <Skeleton width='30%' height={12} /> </View> </View> ))} </View> ); } ``` #### Custom Shapes **Example:** Skeletons with custom shapes and styling ```tsx // components/demo/skeleton/skeleton-shapes.tsx import { Skeleton } from '@/components/ui/skeleton'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { BORDER_RADIUS } from '@/theme/globals'; import React from 'react'; export function SkeletonShapes() { const card = useColor('card'); return ( <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 16, padding: 16, borderRadius: BORDER_RADIUS, backgroundColor: card, }} > {/* Circle */} <Skeleton width={60} height={60} style={{ borderRadius: 30 }} /> {/* Square */} <Skeleton width={60} height={60} style={{ borderRadius: 4 }} /> {/* Rounded Rectangle */} <Skeleton width={120} height={60} style={{ borderRadius: 12 }} /> {/* Pill */} <Skeleton width={100} height={30} style={{ borderRadius: 15 }} /> {/* Custom styled */} <Skeleton width={80} height={80} style={{ borderRadius: 20, transform: [{ rotate: '45deg' }], }} /> </View> ); } ``` ## API Reference ### Skeleton A loading placeholder component with animated pulsing effect. | Prop | Type | Default | Description | | --------- | ------------------------ | ----------- | ------------------------------------------------------------------------------------------------------------------------ | | `width` | `number \| string` | `100%` | The width of the skeleton. | | `height` | `number` | `100` | The height of the skeleton in pixels. | | `style` | `ViewStyle` | - | Additional styles to apply to the skeleton. | | `variant` | `'default' \| 'rounded'` | `'default'` | Corner radius preset: `"default"` uses `CORNERS` (fully rounded), `"rounded"` uses `BORDER_RADIUS` (moderately rounded). | ## Accessibility The Skeleton component is built with accessibility in mind: - Uses appropriate color contrast for loading states - Provides visual feedback during content loading - Maintains layout stability while content loads - Compatible with screen readers through proper semantic structure <!-- ---------------------------------------------------------------------- --> # Spinner > A loading indicator component with multiple variants and customization options. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/spinner - Markdown: https://ui.ahmedbna.com/docs/components/spinner.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/spinner.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/spinner.json - Install: `npx bna-ui add spinner` - npm dependencies: `lucide-react-native`, `react-native-reanimated`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `text` - Preview recording: https://demo.ahmedbna.com/0279-spinner-demo.MP4 --- **Example:** A basic spinner with default styling ```tsx // components/demo/spinner/spinner-demo.tsx import { Spinner } from '@/components/ui/spinner'; import React from 'react'; export function SpinnerDemo() { return <Spinner size='default' variant='default' />; } ``` ## Installation ### CLI ```bash npx bna-ui add spinner ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install lucide-react-native ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/spinner.tsx import { Text } from '@/components/ui/text'; import { useColor } from '@/hooks/useColor'; import { BORDER_RADIUS, CORNERS, FONT_SIZE } from '@/theme/globals'; import { Loader2 } from 'lucide-react-native'; import React, { useEffect, useMemo } from 'react'; import { ActivityIndicator, StyleSheet, View, ViewStyle } from 'react-native'; import Animated, { Easing, SharedValue, useAnimatedStyle, useSharedValue, withDelay, withRepeat, withSequence, withTiming, } from 'react-native-reanimated'; // Types type SpinnerSize = 'default' | 'sm' | 'lg' | 'icon'; export type SpinnerVariant = 'default' | 'circle' | 'dots' | 'pulse' | 'bars'; interface SpinnerProps { size?: SpinnerSize; variant?: SpinnerVariant; label?: string; showLabel?: boolean; style?: ViewStyle; color?: string; thickness?: number; // Only affects the 'circle' variant's stroke width speed?: 'slow' | 'normal' | 'fast'; } interface LoadingOverlayProps extends SpinnerProps { visible: boolean; backdrop?: boolean; backdropColor?: string; backdropOpacity?: number; onRequestClose?: () => void; } interface SpinnerConfig { size: number; iconSize: number; fontSize: number; gap: number; thickness: number; } // Configuration const sizeConfig: Record<SpinnerSize, SpinnerConfig> = { sm: { size: 16, iconSize: 16, fontSize: 12, gap: 6, thickness: 2 }, default: { size: 24, iconSize: 24, fontSize: FONT_SIZE, gap: 8, thickness: 2, }, lg: { size: 32, iconSize: 32, fontSize: 16, gap: 10, thickness: 3 }, icon: { size: 24, iconSize: 24, fontSize: FONT_SIZE, gap: 8, thickness: 2 }, }; const speedConfig = { slow: 1500, normal: 1000, fast: 500, }; // --- Helper Animated Components for Dots and Bars --- interface AnimatedShapeProps { anim: SharedValue<number>; color: string; size: number; style: ViewStyle; } const AnimatedDot = React.memo( ({ anim, color, size, style }: AnimatedShapeProps) => { const animatedStyle = useAnimatedStyle(() => ({ opacity: anim.value, })); return ( <Animated.View style={[ style, { width: size, height: size, backgroundColor: color }, animatedStyle, ]} /> ); } ); const AnimatedBar = React.memo( ({ anim, color, size, style }: AnimatedShapeProps) => { const animatedStyle = useAnimatedStyle(() => ({ opacity: anim.value, })); return ( <Animated.View style={[ style, { width: size / 6, height: size, backgroundColor: color }, animatedStyle, ]} /> ); } ); // Main Spinner Component export function Spinner({ size = 'default', variant = 'default', label, showLabel = false, style, color, thickness, speed = 'normal', }: SpinnerProps) { // Reanimated shared values const rotate = useSharedValue(0); const pulse = useSharedValue(1); // --- FIX: Call hooks at the top level --- // 1. Call useSharedValue at the top level for each dot/bar const dotAnim1 = useSharedValue(0.3); const dotAnim2 = useSharedValue(0.3); const dotAnim3 = useSharedValue(0.3); const barAnim1 = useSharedValue(0.3); const barAnim2 = useSharedValue(0.3); const barAnim3 = useSharedValue(0.3); const barAnim4 = useSharedValue(0.3); // 2. Use useMemo to create a stable array reference from the values const dotsAnims = useMemo( () => [dotAnim1, dotAnim2, dotAnim3], [dotAnim1, dotAnim2, dotAnim3] ); const barsAnims = useMemo( () => [barAnim1, barAnim2, barAnim3, barAnim4], [barAnim1, barAnim2, barAnim3, barAnim4] ); // --- END FIX --- // Theme colors const primaryColor = useColor('text'); const textColor = useColor('text'); const config = sizeConfig[size]; const spinnerColor = color || primaryColor; const animationDuration = speedConfig[speed]; // Rotation animation useEffect(() => { if (variant === 'circle') { rotate.value = withRepeat( withTiming(360, { duration: animationDuration, easing: Easing.linear }), -1 ); } else { rotate.value = 0; // Reset } }, [rotate, variant, animationDuration]); // Pulse animation useEffect(() => { if (variant === 'pulse') { pulse.value = withRepeat( withSequence( withTiming(1.3, { duration: animationDuration / 2 }), withTiming(1, { duration: animationDuration / 2 }) ), -1, true ); } else { pulse.value = 1; // Reset } }, [pulse, variant, animationDuration]); // Dots animation useEffect(() => { if (variant === 'dots') { dotsAnims.forEach((anim, index) => { anim.value = withRepeat( withSequence( withDelay( index * (animationDuration / 6), withTiming(1, { duration: animationDuration / 3 }) ), withTiming(0.3, { duration: animationDuration / 3 }) ), -1 ); }); } else { dotsAnims.forEach((anim) => (anim.value = 0.3)); // Reset } }, [dotsAnims, variant, animationDuration]); // Bars animation useEffect(() => { if (variant === 'bars') { barsAnims.forEach((anim, index) => { anim.value = withRepeat( withSequence( withDelay( index * (animationDuration / 8), withTiming(1, { duration: animationDuration / 4 }) ), withTiming(0.3, { duration: animationDuration / 4 }) ), -1 ); }); } else { barsAnims.forEach((anim) => (anim.value = 0.3)); // Reset } }, [barsAnims, variant, animationDuration]); // Animated styles const animatedCircleStyle = useAnimatedStyle(() => ({ transform: [{ rotate: `${rotate.value}deg` }], })); const animatedPulseStyle = useAnimatedStyle(() => ({ transform: [{ scale: pulse.value }], })); const renderSpinner = () => { switch (variant) { case 'default': return ( <ActivityIndicator size={config.size} color={spinnerColor} style={styles.spinner} /> ); case 'circle': return ( <Animated.View style={[ styles.customSpinner, { width: config.size, height: config.size }, animatedCircleStyle, ]} > <Loader2 size={config.iconSize} color={spinnerColor} strokeWidth={thickness ?? config.thickness} /> </Animated.View> ); case 'pulse': return ( <Animated.View style={[ styles.pulseSpinner, { width: config.size, height: config.size, backgroundColor: spinnerColor, }, animatedPulseStyle, ]} /> ); case 'dots': return ( <View style={[styles.dotsContainer, { gap: config.size / 4 }]}> {dotsAnims.map((anim, index) => ( <AnimatedDot key={index} anim={anim} color={spinnerColor} size={config.size / 3} style={styles.dot} /> ))} </View> ); case 'bars': return ( <View style={[styles.barsContainer, { gap: config.size / 6 }]}> {barsAnims.map((anim, index) => ( <AnimatedBar key={index} anim={anim} color={spinnerColor} size={config.size} style={styles.bar} /> ))} </View> ); default: return null; } }; const containerStyle: ViewStyle = { alignItems: 'center', justifyContent: 'center', gap: config.gap, }; return ( <View style={[containerStyle, style]} accessibilityRole='progressbar' accessibilityLabel={label || 'Loading'} > {renderSpinner()} {(showLabel || label) && ( <Text style={[ styles.label, { color: textColor, fontSize: config.fontSize, }, ]} > {label || 'Loading...'} </Text> )} </View> ); } // Loading Overlay Component export function LoadingOverlay({ visible, backdrop = true, backdropColor, backdropOpacity = 0.5, ...spinnerProps }: LoadingOverlayProps) { const opacity = useSharedValue(0); const backgroundColor = useColor('background'); const cardColor = useColor('card'); useEffect(() => { opacity.value = withTiming(visible ? 1 : 0, { duration: 200, }); }, [visible, opacity]); const animatedOverlayStyle = useAnimatedStyle(() => ({ opacity: opacity.value, // Conditionally render to avoid interaction issues display: opacity.value === 0 ? 'none' : 'flex', })); const defaultBackdropColor = backdropColor || `${backgroundColor}${Math.round(backdropOpacity * 255) .toString(16) .padStart(2, '0')}`; return ( <Animated.View style={[ styles.overlay, { backgroundColor: backdrop ? defaultBackdropColor : 'transparent' }, animatedOverlayStyle, ]} pointerEvents={visible ? 'auto' : 'none'} > <View style={[styles.overlayContent, { backgroundColor: cardColor }]}> <Spinner {...spinnerProps} /> </View> </Animated.View> ); } // Inline Loader Component (for buttons, etc.) export function InlineLoader({ size = 'sm', variant = 'default', color, }: Omit<SpinnerProps, 'label' | 'showLabel'>) { return ( <Spinner size={size} variant={variant} color={color} style={styles.inlineLoader} /> ); } // Button Spinner Component - optimized for button usage export function ButtonSpinner({ size = 'sm', variant = 'default', color, }: Omit<SpinnerProps, 'label' | 'showLabel'>) { const primaryForegroundColor = useColor('primaryForeground'); return ( <Spinner size={size} variant={variant} color={color || primaryForegroundColor} style={styles.buttonSpinner} /> ); } const styles = StyleSheet.create({ spinner: { alignSelf: 'center', }, customSpinner: { alignItems: 'center', justifyContent: 'center', }, pulseSpinner: { borderRadius: 999, }, dotsContainer: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', }, dot: { borderRadius: 999, }, barsContainer: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', }, bar: { borderRadius: CORNERS, }, label: { textAlign: 'center', fontWeight: '500', }, overlay: { ...StyleSheet.absoluteFill, alignItems: 'center', justifyContent: 'center', zIndex: 9999, }, overlayContent: { padding: 60, borderRadius: BORDER_RADIUS, }, inlineLoader: { minHeight: 0, minWidth: 0, }, buttonSpinner: { minHeight: 0, minWidth: 0, }, }); ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { Spinner, LoadingOverlay, InlineLoader, ButtonSpinner, } from '@/components/ui/spinner'; ``` ```tsx <Spinner size='default' variant='default' /> ``` ## Examples #### Default **Example:** A basic spinner with default styling ```tsx // components/demo/spinner/spinner-demo.tsx import { Spinner } from '@/components/ui/spinner'; import React from 'react'; export function SpinnerDemo() { return <Spinner size='default' variant='default' />; } ``` #### Variants **Example:** Different spinner variants: default, circle, dots, pulse, and bars ```tsx // components/demo/spinner/spinner-variants.tsx import { Spinner } from '@/components/ui/spinner'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function SpinnerVariants() { const variants = [ { variant: 'default' as const, label: 'Default' }, { variant: 'circle' as const, label: 'Circle' }, { variant: 'dots' as const, label: 'Dots' }, { variant: 'pulse' as const, label: 'Pulse' }, { variant: 'bars' as const, label: 'Bars' }, ]; return ( <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 24 }}> {variants.map(({ variant, label }) => ( <View key={variant} style={{ alignItems: 'center', gap: 8 }}> <Spinner variant={variant} size='default' /> <Text variant='caption' style={{ textAlign: 'center' }}> {label} </Text> </View> ))} </View> ); } ``` #### Sizes **Example:** Spinners in different sizes: sm, default, lg, and icon ```tsx // components/demo/spinner/spinner-sizes.tsx import { Spinner } from '@/components/ui/spinner'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function SpinnerSizes() { const sizes = [ { size: 'sm' as const, label: 'Small' }, { size: 'default' as const, label: 'Default' }, { size: 'lg' as const, label: 'Large' }, { size: 'icon' as const, label: 'Icon' }, ]; return ( <View style={{ flexDirection: 'row', alignItems: 'center', gap: 32 }}> {sizes.map(({ size, label }) => ( <View key={size} style={{ alignItems: 'center', gap: 8 }}> <Spinner size={size} variant='circle' /> <Text variant='caption' style={{ textAlign: 'center' }}> {label} </Text> </View> ))} </View> ); } ``` #### With Labels **Example:** Spinners with custom loading labels ```tsx // components/demo/spinner/spinner-labels.tsx import { Spinner } from '@/components/ui/spinner'; import { View } from '@/components/ui/view'; import React from 'react'; export function SpinnerLabels() { return ( <View style={{ gap: 24 }}> <Spinner size='default' variant='default' showLabel /> <Spinner size='default' variant='dots' label='Processing...' /> <Spinner size='default' variant='pulse' label='Uploading files...' /> <Spinner size='lg' variant='circle' label='Please wait' /> </View> ); } ``` #### Speed Control **Example:** Spinners with different animation speeds: slow, normal, and fast ```tsx // components/demo/spinner/spinner-speeds.tsx import { Spinner } from '@/components/ui/spinner'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function SpinnerSpeeds() { const speeds = [ { speed: 'slow' as const, label: 'Slow' }, { speed: 'normal' as const, label: 'Normal' }, { speed: 'fast' as const, label: 'Fast' }, ]; return ( <View style={{ flexDirection: 'row', gap: 32 }}> {speeds.map(({ speed, label }) => ( <View key={speed} style={{ alignItems: 'center', gap: 8 }}> <Spinner variant='circle' size='default' speed={speed} /> <Text variant='caption' style={{ textAlign: 'center' }}> {label} </Text> </View> ))} </View> ); } ``` #### Custom Colors **Example:** Spinners with custom colors and styling ```tsx // components/demo/spinner/spinner-colors.tsx import { Spinner } from '@/components/ui/spinner'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function SpinnerColors() { const colors = [ { color: '#3b82f6', label: 'Blue', variant: 'default' as const }, { color: '#10b981', label: 'Green', variant: 'dots' as const }, { color: '#f59e0b', label: 'Orange', variant: 'pulse' as const }, { color: '#ef4444', label: 'Red', variant: 'bars' as const }, { color: '#8b5cf6', label: 'Purple', variant: 'circle' as const }, ]; return ( <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 24 }}> {colors.map(({ color, label, variant }) => ( <View key={color} style={{ alignItems: 'center', gap: 8 }}> <Spinner variant={variant} size='default' color={color} /> <Text variant='caption' style={{ textAlign: 'center' }}> {label} </Text> </View> ))} </View> ); } ``` #### Loading Overlay **Example:** Full-screen loading overlay with backdrop ```tsx // components/demo/spinner/spinner-overlay.tsx import { Button } from '@/components/ui/button'; import { LoadingOverlay } from '@/components/ui/spinner'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function SpinnerOverlay() { const [showOverlay, setShowOverlay] = useState(false); const handleShowOverlay = () => { setShowOverlay(true); // Auto hide after 3 seconds for demo setTimeout(() => setShowOverlay(false), 3000); }; return ( <View style={{ gap: 16 }}> <Button onPress={handleShowOverlay} disabled={showOverlay}> Show Loading Overlay </Button> <LoadingOverlay visible={showOverlay} size='lg' variant='circle' label='Loading content...' backdrop={true} backdropOpacity={0.7} /> </View> ); } ``` #### Inline Loader **Example:** Small spinners for inline usage in buttons or text ```tsx // components/demo/spinner/spinner-inline.tsx import { InlineLoader } from '@/components/ui/spinner'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function SpinnerInline() { return ( <View style={{ gap: 16 }}> <View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}> <Text>Loading data</Text> <InlineLoader size='sm' variant='default' /> </View> <View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}> <Text>Processing</Text> <InlineLoader size='sm' variant='dots' /> </View> <View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}> <InlineLoader size='sm' variant='pulse' color='#10b981' /> <Text>Syncing...</Text> </View> </View> ); } ``` ## API Reference ### Spinner The main spinner component with multiple variants and customization options. | Prop | Type | Default | Description | | ----------- | ------------------------------------------------------ | ----------- | ----------------------------------------------------------------------------------------- | | `size` | `'default' \| 'sm' \| 'lg' \| 'icon'` | `'default'` | The size of the spinner. | | `variant` | `'default' \| 'circle' \| 'dots' \| 'pulse' \| 'bars'` | `'default'` | The visual variant of the spinner. | | `label` | `string` | - | Optional label text to display with spinner. | | `showLabel` | `boolean` | `false` | Whether to show the default "Loading..." label. | | `style` | `ViewStyle` | - | Additional styles to apply to the container. | | `color` | `string` | - | Custom color for the spinner. | | `speed` | `'slow' \| 'normal' \| 'fast'` | `'normal'` | Animation speed of the spinner. | | `thickness` | `number` | - | Stroke width of the icon used by the `'circle'` variant. Has no effect on other variants. | ### LoadingOverlay A full-screen overlay component with spinner for blocking UI interactions during loading. | Prop | Type | Default | Description | | ----------------- | -------------- | ------- | ---------------------------------------------- | | `visible` | `boolean` | - | Whether the overlay is visible. | | `backdrop` | `boolean` | `true` | Whether to show a backdrop behind the spinner. | | `backdropColor` | `string` | - | Custom backdrop color. | | `backdropOpacity` | `number` | `0.5` | Opacity of the backdrop (0-1). | | `onRequestClose` | `() => void` | - | Callback when overlay should be closed. | | `...spinnerProps` | `SpinnerProps` | - | All props from the Spinner component. | ### InlineLoader A compact spinner optimized for inline usage within text or small containers. | Prop | Type | Default | Description | | --------- | ------------------------------------------------------ | ----------- | ---------------------------------- | | `size` | `'default' \| 'sm' \| 'lg' \| 'icon'` | `'sm'` | The size of the spinner. | | `variant` | `'default' \| 'circle' \| 'dots' \| 'pulse' \| 'bars'` | `'default'` | The visual variant of the spinner. | | `color` | `string` | - | Custom color for the spinner. | ### ButtonSpinner A spinner component specifically designed for button loading states. | Prop | Type | Default | Description | | --------- | ------------------------------------------------------ | ----------- | ---------------------------------- | | `size` | `'default' \| 'sm' \| 'lg' \| 'icon'` | `'sm'` | The size of the spinner. | | `variant` | `'default' \| 'circle' \| 'dots' \| 'pulse' \| 'bars'` | `'default'` | The visual variant of the spinner. | | `color` | `string` | - | Custom color for the spinner. | ## Accessibility The Spinner component is built with accessibility in mind: - Provides meaningful loading states for screen readers - Supports custom labels for better context - Maintains proper contrast ratios for visibility - Non-intrusive animations that respect user preferences - Loading overlays properly manage focus and interaction states <!-- ---------------------------------------------------------------------- --> # Switch > A control that allows the user to toggle between checked and not checked states. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/switch - Markdown: https://ui.ahmedbna.com/docs/components/switch.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/switch.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/switch.json - Install: `npx bna-ui add switch` - npm dependencies: `expo-haptics` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `useHaptics`, `globals`, `text`, `view` - Preview recording: https://demo.ahmedbna.com/0287-switch-demo.MP4 --- **Example:** A basic switch with label ```tsx // components/demo/switch/switch-demo.tsx import { Switch } from '@/components/ui/switch'; import React, { useState } from 'react'; export function SwitchDemo() { const [isEnabled, setIsEnabled] = useState(false); return ( <Switch label='Enable notifications' value={isEnabled} onValueChange={setIsEnabled} /> ); } ``` ## Installation ### CLI ```bash npx bna-ui add switch ``` ### Manual **1.** Copy and paste the following code into your project. ```tsx // components/ui/switch.tsx import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { useHaptics } from '@/hooks/useHaptics'; import React from 'react'; import { Switch as RNSwitch, SwitchProps as RNSwitchProps, TextStyle, } from 'react-native'; interface SwitchProps extends RNSwitchProps { label?: string; error?: string; labelStyle?: TextStyle; haptic?: boolean; } export function Switch({ label, error, labelStyle, haptic = true, onValueChange, ...props }: SwitchProps) { const mutedColor = useColor('muted'); const primary = useColor('primary'); const danger = useColor('red'); const feedback = useHaptics(haptic); const handleValueChange = React.useCallback( (value: boolean) => { feedback(value ? 'toggle-on' : 'toggle-off'); onValueChange?.(value); }, [feedback, onValueChange] ); return ( <View style={{ marginBottom: 8 }}> <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', minHeight: 32, // Ensure consistent height }} > {label && ( <Text variant='caption' numberOfLines={2} // Allow wrapping for longer labels ellipsizeMode='tail' style={[ { color: error ? danger : primary, flex: 1, // Take available space marginRight: 12, // Add spacing between label and switch }, labelStyle, ]} pointerEvents='none' > {label} </Text> )} <RNSwitch trackColor={{ false: mutedColor, true: '#7DD87D' }} thumbColor={props.value ? '#ffffff' : '#f4f3f4'} accessibilityLabel={label} {...props} onValueChange={handleValueChange} /> </View> {error && ( <Text variant='caption' numberOfLines={2} ellipsizeMode='tail' style={[ { fontSize: 12, // Slightly smaller for error text color: danger, // Always use danger color for errors marginTop: 4, // Add spacing above error text }, ]} pointerEvents='none' > {error} </Text> )} </View> ); } ``` **2.** Update the import paths to match your project setup. ## Usage ```tsx import { Switch } from '@/components/ui/switch'; ``` ```tsx <Switch label='Enable notifications' value={isEnabled} onValueChange={setIsEnabled} /> ``` ## Examples #### Default **Example:** A basic switch with label ```tsx // components/demo/switch/switch-demo.tsx import { Switch } from '@/components/ui/switch'; import React, { useState } from 'react'; export function SwitchDemo() { const [isEnabled, setIsEnabled] = useState(false); return ( <Switch label='Enable notifications' value={isEnabled} onValueChange={setIsEnabled} /> ); } ``` #### Without Label **Example:** A switch without label text ```tsx // components/demo/switch/switch-simple.tsx import { Switch } from '@/components/ui/switch'; import React, { useState } from 'react'; export function SwitchSimple() { const [isEnabled, setIsEnabled] = useState(false); return <Switch value={isEnabled} onValueChange={setIsEnabled} />; } ``` #### With Error State **Example:** Switch with error message and styling ```tsx // components/demo/switch/switch-error.tsx import { Switch } from '@/components/ui/switch'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function SwitchError() { const [isEnabled, setIsEnabled] = useState(false); return ( <View style={{ gap: 46 }}> <Switch label='Terms and conditions' value={isEnabled} onValueChange={setIsEnabled} /> <Switch label='Privacy policy' value={false} onValueChange={() => {}} error='You must accept the privacy policy' /> </View> ); } ``` #### Disabled State **Example:** Switches in disabled state ```tsx // components/demo/switch/switch-disabled.tsx import { Switch } from '@/components/ui/switch'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function SwitchDisabled() { const [value, setValue] = useState(false); return ( <View style={{ gap: 12 }}> <Switch value={value} label='Disabled (Off)' onValueChange={setValue} /> <Switch label='Disabled (On)' value={true} onValueChange={() => {}} disabled={true} /> </View> ); } ``` #### Settings List **Example:** Multiple switches arranged in a settings list ```tsx // components/demo/switch/switch-settings.tsx import { Switch } from '@/components/ui/switch'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { BORDER_RADIUS } from '@/theme/globals'; import React, { useState } from 'react'; export function SwitchSettings() { const card = useColor('card'); const [notifications, setNotifications] = useState(true); const [darkMode, setDarkMode] = useState(false); const [location, setLocation] = useState(true); const [analytics, setAnalytics] = useState(false); return ( <View style={{ backgroundColor: card, borderRadius: BORDER_RADIUS, padding: 16, gap: 16, }} > <Switch label='Push notifications' value={notifications} onValueChange={setNotifications} /> <Switch label='Dark mode' value={darkMode} onValueChange={setDarkMode} /> <Switch label='Location services' value={location} onValueChange={setLocation} /> <Switch label='Analytics & performance' value={analytics} onValueChange={setAnalytics} /> </View> ); } ``` #### Custom Colors **Example:** Switches with custom colors and styling ```tsx // components/demo/switch/switch-colors.tsx import { Switch } from '@/components/ui/switch'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function SwitchColors() { const [switch1, setSwitch1] = useState(true); const [switch2, setSwitch2] = useState(true); const [switch3, setSwitch3] = useState(true); return ( <View style={{ gap: 12 }}> <Switch label='Default green' value={switch1} onValueChange={setSwitch1} /> <Switch label='Custom blue' value={switch2} onValueChange={setSwitch2} trackColor={{ false: '#e0e0e0', true: '#2196F3' }} thumbColor={switch2 ? '#ffffff' : '#f4f3f4'} /> <Switch label='Custom purple' value={switch3} onValueChange={setSwitch3} trackColor={{ false: '#e0e0e0', true: '#9C27B0' }} thumbColor={switch3 ? '#ffffff' : '#f4f3f4'} /> </View> ); } ``` ## API Reference ### Switch A toggle switch component with optional label and error states. | Prop | Type | Default | Description | | --------------- | --------------------------------- | ------- | -------------------------------------------------------------- | | `haptic` | `boolean` | `true` | Whether to trigger haptic feedback when the switch is toggled. | | `label` | `string` | - | Optional label text for the switch. | | `error` | `string` | - | Error message to display (changes label color). | | `labelStyle` | `TextStyle` | - | Additional styles to apply to the label text. | | `value` | `boolean` | - | The current state of the switch. | | `onValueChange` | `(value: boolean) => void` | - | Callback fired when the switch state changes. | | `disabled` | `boolean` | `false` | Whether the switch is disabled. | | `...props` | `SwitchProps` (from React Native) | - | All other React Native Switch props are supported. | ## Accessibility The Switch component is built with accessibility in mind: - Uses native React Native Switch for optimal platform behavior - Supports screen reader announcements for state changes - Proper focus management and keyboard navigation - Color contrast meets accessibility standards - Label text is properly associated with the switch control - Error states provide clear feedback to assistive technologies <!-- ---------------------------------------------------------------------- --> # Table > A flexible data table component with sorting, filtering, pagination, and search functionality. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/table - Markdown: https://ui.ahmedbna.com/docs/components/table.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/table.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/table.json - Install: `npx bna-ui add table` - npm dependencies: `expo-haptics`, `lucide-react-native`, `react-native-reanimated`, `react-native-svg`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `useHaptics`, `text`, `view`, `icon`, `spinner`, `button` - Preview recording: https://demo.ahmedbna.com/0293-table-demo.MP4 --- **Example:** A basic data table with sample data ```tsx // components/demo/table/table-demo.tsx import { Table, TableColumn } from '@/components/ui/table'; import React from 'react'; interface User { id: number; name: string; email: string; role: string; status: 'Active' | 'Inactive'; } const sampleData: User[] = [ { id: 1, name: 'John Doe', email: 'john@example.com', role: 'Admin', status: 'Active', }, { id: 2, name: 'Jane Smith', email: 'jane@example.com', role: 'User', status: 'Active', }, { id: 3, name: 'Bob Johnson', email: 'bob@example.com', role: 'Manager', status: 'Inactive', }, { id: 4, name: 'Alice Brown', email: 'alice@example.com', role: 'User', status: 'Active', }, { id: 5, name: 'Charlie Wilson', email: 'charlie@example.com', role: 'Admin', status: 'Active', }, ]; const columns: TableColumn<User>[] = [ { id: 'name', header: 'Name', accessorKey: 'name', sortable: true, filterable: true, }, { id: 'email', header: 'Email', accessorKey: 'email', sortable: true, filterable: true, }, { id: 'role', header: 'Role', accessorKey: 'role', sortable: true, filterable: true, }, { id: 'status', header: 'Status', accessorKey: 'status', sortable: true, filterable: true, }, ]; export function TableDemo() { return ( <Table data={sampleData} columns={columns} pageSize={5} searchPlaceholder='Search users...' /> ); } ``` ## Installation ### CLI ```bash npx bna-ui add table ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install lucide-react-native ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/table.tsx import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { BORDER_RADIUS, FONT_SIZE, HEIGHT } from '@/theme/globals'; import { ChevronDown, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ChevronUp, Search, } from 'lucide-react-native'; import React, { useMemo, useState } from 'react'; import { FlatList, ScrollView, TextInput, TextStyle, TouchableOpacity, ViewStyle, } from 'react-native'; // Types export interface TableColumn<T = any> { id: string; header: string; accessorKey: string; sortable?: boolean; filterable?: boolean; width?: number | string; minWidth?: number; cell?: (value: any, row: T) => React.ReactNode; headerCell?: () => React.ReactNode; align?: 'left' | 'center' | 'right'; } export interface TableProps<T = any> { data: T[]; columns: TableColumn<T>[]; pagination?: boolean; pageSize?: number; searchable?: boolean; searchPlaceholder?: string; loading?: boolean; emptyMessage?: string; style?: ViewStyle; headerStyle?: ViewStyle; rowStyle?: ViewStyle; cellStyle?: ViewStyle; onRowPress?: (row: T, index: number) => void; sortable?: boolean; filterable?: boolean; } type SortDirection = 'asc' | 'desc' | null; interface SortState { column: string | null; direction: SortDirection; } export function Table<T = any>({ data, columns, pagination = true, pageSize = 10, searchable = true, searchPlaceholder = 'Search...', loading = false, emptyMessage = 'No data available', style, headerStyle, rowStyle, cellStyle, onRowPress, sortable = true, filterable = true, }: TableProps<T>) { // Theme colors const borderColor = useColor('border'); const textColor = useColor('text'); const mutedColor = useColor('textMuted'); const cardColor = useColor('card'); const primaryColor = useColor('primary'); // State const [currentPage, setCurrentPage] = useState(1); const [searchQuery, setSearchQuery] = useState(''); const [sortState, setSortState] = useState<SortState>({ column: null, direction: null, }); // Filter and sort data const filteredAndSortedData = useMemo(() => { let processedData = [...data]; // Apply search filter if (searchQuery && filterable) { processedData = processedData.filter((row) => columns.some((column) => { if (!column.filterable) return false; const value = (row as any)[column.accessorKey]; return String(value || '') .toLowerCase() .includes(searchQuery.toLowerCase()); }) ); } // Apply sorting if (sortState.column && sortState.direction && sortable) { processedData.sort((a, b) => { const aValue = (a as any)[sortState.column!]; const bValue = (b as any)[sortState.column!]; if (aValue === null || aValue === undefined) return 1; if (bValue === null || bValue === undefined) return -1; if (typeof aValue === 'string' && typeof bValue === 'string') { const comparison = aValue.localeCompare(bValue); return sortState.direction === 'asc' ? comparison : -comparison; } if (aValue < bValue) return sortState.direction === 'asc' ? -1 : 1; if (aValue > bValue) return sortState.direction === 'asc' ? 1 : -1; return 0; }); } return processedData; }, [data, searchQuery, sortState, columns, filterable, sortable]); // Pagination const totalPages = pagination ? Math.ceil(filteredAndSortedData.length / pageSize) : 1; const startIndex = pagination ? (currentPage - 1) * pageSize : 0; const endIndex = pagination ? startIndex + pageSize : filteredAndSortedData.length; const paginatedData = filteredAndSortedData.slice(startIndex, endIndex); // Handlers const handleSort = (columnId: string) => { if (!sortable) return; const column = columns.find((col) => col.id === columnId); if (!column?.sortable) return; setSortState((prev) => { if (prev.column === columnId) { // Cycle through: asc -> desc -> null const newDirection: SortDirection = prev.direction === 'asc' ? 'desc' : prev.direction === 'desc' ? null : 'asc'; return { column: newDirection ? columnId : null, direction: newDirection, }; } else { return { column: columnId, direction: 'asc' }; } }); }; const handlePageChange = (page: number) => { setCurrentPage(Math.max(1, Math.min(page, totalPages))); }; const renderSortIcon = (columnId: string) => { if (!sortable) return null; const column = columns.find((col) => col.id === columnId); if (!column?.sortable) return null; if (sortState.column !== columnId) { return ( <ChevronUp size={16} color={mutedColor} style={{ opacity: 0.3 }} /> ); } return sortState.direction === 'asc' ? ( <ChevronUp size={16} color={primaryColor} /> ) : ( <ChevronDown size={16} color={primaryColor} /> ); }; const renderCell = (column: TableColumn<T>, row: T, rowIndex: number) => { const value = (row as any)[column.accessorKey]; const cellContent = column.cell ? column.cell(value, row) : String(value || ''); const alignStyle: TextStyle = { textAlign: column.align || 'left', }; return ( <View key={column.id} style={[ { flex: column.width ? 0 : 1, width: column.width as any, minWidth: column.minWidth || 100, paddingHorizontal: 18, paddingVertical: 16, justifyContent: 'center', }, cellStyle, ]} > {typeof cellContent === 'string' ? ( <Text style={[{ fontSize: FONT_SIZE }, alignStyle]}> {cellContent} </Text> ) : ( cellContent )} </View> ); }; const renderHeader = () => ( <View style={[ { flexDirection: 'row', backgroundColor: cardColor, borderBottomWidth: 1, borderBottomColor: borderColor, }, headerStyle, ]} > {columns.map((column) => ( <TouchableOpacity key={column.id} style={{ flex: column.width ? 0 : 1, width: column.width as any, minWidth: column.minWidth || 100, paddingHorizontal: 18, paddingVertical: 16, flexDirection: 'row', alignItems: 'center', justifyContent: column.align === 'center' ? 'center' : column.align === 'right' ? 'flex-end' : 'flex-start', }} onPress={() => handleSort(column.id)} disabled={!column.sortable || !sortable} accessibilityRole={column.sortable && sortable ? 'button' : undefined} accessibilityLabel={ column.sortable && sortable ? `${column.header}, ${ sortState.column === column.id ? sortState.direction === 'asc' ? 'sorted ascending' : 'sorted descending' : 'not sorted' }` : column.header } > {column.headerCell ? ( column.headerCell() ) : ( <> <Text variant='subtitle' style={{ marginRight: column.sortable && sortable ? 4 : 0, textAlign: column.align || 'left', }} > {column.header} </Text> {renderSortIcon(column.id)} </> )} </TouchableOpacity> ))} </View> ); const renderRow = (row: T, index: number) => ( <TouchableOpacity key={index} style={[ { flexDirection: 'row', backgroundColor: cardColor, borderBottomWidth: 1, borderBottomColor: borderColor, }, rowStyle, ]} onPress={() => onRowPress?.(row, index)} disabled={!onRowPress} activeOpacity={onRowPress ? 0.7 : 1} > {columns.map((column) => renderCell(column, row, index))} </TouchableOpacity> ); const renderPagination = () => { if (!pagination || totalPages <= 1) return null; return ( <View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 16, paddingVertical: 18, backgroundColor: cardColor, borderTopWidth: 1, borderTopColor: borderColor, }} > <Text variant='caption'> Page {currentPage} of {totalPages} ({filteredAndSortedData.length}{' '} total) </Text> <View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}> <Button variant='outline' size='sm' onPress={() => handlePageChange(1)} disabled={currentPage === 1} > <ChevronsLeft size={16} color={currentPage === 1 ? mutedColor : textColor} /> </Button> <Button variant='outline' size='sm' onPress={() => handlePageChange(currentPage - 1)} disabled={currentPage === 1} > <ChevronLeft size={16} color={currentPage === 1 ? mutedColor : textColor} /> </Button> <Button variant='outline' size='sm' onPress={() => handlePageChange(currentPage + 1)} disabled={currentPage === totalPages} > <ChevronRight size={16} color={currentPage === totalPages ? mutedColor : textColor} /> </Button> <Button variant='outline' size='sm' onPress={() => handlePageChange(totalPages)} disabled={currentPage === totalPages} > <ChevronsRight size={16} color={currentPage === totalPages ? mutedColor : textColor} /> </Button> </View> </View> ); }; const renderSearchBar = () => { if (!searchable || !filterable) return null; return ( <View style={{ flexDirection: 'row', alignItems: 'center', backgroundColor: cardColor, borderBottomWidth: 1, borderColor: borderColor, paddingHorizontal: 18, height: HEIGHT, marginVertical: 2, }} > <Search size={16} color={mutedColor} style={{ marginRight: 8 }} /> <TextInput style={{ flex: 1, fontSize: FONT_SIZE, color: textColor, paddingVertical: 8, }} placeholder={searchPlaceholder} placeholderTextColor={mutedColor} value={searchQuery} onChangeText={setSearchQuery} /> </View> ); }; const renderEmptyState = () => ( <View style={{ padding: 32, alignItems: 'center', justifyContent: 'center', backgroundColor: cardColor, }} > <Text variant='body' style={{ color: mutedColor }}> {emptyMessage} </Text> </View> ); const renderLoadingState = () => ( <View style={{ padding: 32, alignItems: 'center', justifyContent: 'center', backgroundColor: cardColor, }} > <Text variant='body' style={{ color: mutedColor }}> Loading... </Text> </View> ); return ( <View style={[ { width: '100%', borderRadius: BORDER_RADIUS, borderWidth: 1, borderColor: borderColor, backgroundColor: cardColor, overflow: 'hidden', }, style, ]} > {renderSearchBar()} <ScrollView horizontal showsHorizontalScrollIndicator={false}> <View style={{ minWidth: '100%' }}> {renderHeader()} {loading ? ( renderLoadingState() ) : paginatedData.length === 0 ? ( renderEmptyState() ) : ( <FlatList data={paginatedData} keyExtractor={(_, index) => String(index)} renderItem={({ item, index }) => renderRow(item, index)} showsVerticalScrollIndicator={false} // Rows aren't bounded by pageSize when pagination={false} — // without virtualization this was the only size guard missing // from an otherwise generic, potentially large data table. /> )} </View> </ScrollView> {renderPagination()} </View> ); } ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { Table, TableColumn } from '@/components/ui/table'; ``` ```tsx const data = [ { id: 1, name: 'John Doe', email: 'john@example.com', role: 'Admin' }, { id: 2, name: 'Jane Smith', email: 'jane@example.com', role: 'User' }, ]; const columns: TableColumn[] = [ { id: 'name', header: 'Name', accessorKey: 'name', sortable: true }, { id: 'email', header: 'Email', accessorKey: 'email', sortable: true }, { id: 'role', header: 'Role', accessorKey: 'role', filterable: true }, ]; <Table data={data} columns={columns} />; ``` ## Examples #### Basic Table **Example:** A basic data table with sample data ```tsx // components/demo/table/table-demo.tsx import { Table, TableColumn } from '@/components/ui/table'; import React from 'react'; interface User { id: number; name: string; email: string; role: string; status: 'Active' | 'Inactive'; } const sampleData: User[] = [ { id: 1, name: 'John Doe', email: 'john@example.com', role: 'Admin', status: 'Active', }, { id: 2, name: 'Jane Smith', email: 'jane@example.com', role: 'User', status: 'Active', }, { id: 3, name: 'Bob Johnson', email: 'bob@example.com', role: 'Manager', status: 'Inactive', }, { id: 4, name: 'Alice Brown', email: 'alice@example.com', role: 'User', status: 'Active', }, { id: 5, name: 'Charlie Wilson', email: 'charlie@example.com', role: 'Admin', status: 'Active', }, ]; const columns: TableColumn<User>[] = [ { id: 'name', header: 'Name', accessorKey: 'name', sortable: true, filterable: true, }, { id: 'email', header: 'Email', accessorKey: 'email', sortable: true, filterable: true, }, { id: 'role', header: 'Role', accessorKey: 'role', sortable: true, filterable: true, }, { id: 'status', header: 'Status', accessorKey: 'status', sortable: true, filterable: true, }, ]; export function TableDemo() { return ( <Table data={sampleData} columns={columns} pageSize={5} searchPlaceholder='Search users...' /> ); } ``` #### Sortable Columns **Example:** Table with sortable columns ```tsx // components/demo/table/table-sortable.tsx import { Table, TableColumn } from '@/components/ui/table'; import React from 'react'; interface Product { id: number; name: string; price: number; category: string; inStock: boolean; rating: number; } const products: Product[] = [ { id: 1, name: 'Laptop Pro', price: 1299.99, category: 'Electronics', inStock: true, rating: 4.5, }, { id: 2, name: 'Wireless Mouse', price: 29.99, category: 'Electronics', inStock: true, rating: 4.2, }, { id: 3, name: 'Coffee Mug', price: 12.99, category: 'Kitchen', inStock: false, rating: 4.0, }, { id: 4, name: 'Desk Chair', price: 199.99, category: 'Furniture', inStock: true, rating: 4.7, }, { id: 5, name: 'Notebook', price: 5.99, category: 'Office', inStock: true, rating: 3.8, }, { id: 6, name: 'Smartphone', price: 699.99, category: 'Electronics', inStock: true, rating: 4.3, }, ]; const columns: TableColumn<Product>[] = [ { id: 'name', header: 'Product Name', accessorKey: 'name', sortable: true, filterable: true, minWidth: 150, }, { id: 'price', header: 'Price', accessorKey: 'price', sortable: true, align: 'right', cell: (value) => `$${value.toFixed(2)}`, minWidth: 100, }, { id: 'category', header: 'Category', accessorKey: 'category', sortable: true, filterable: true, minWidth: 120, }, { id: 'inStock', header: 'In Stock', accessorKey: 'inStock', sortable: true, align: 'center', cell: (value) => (value ? '✅' : '❌'), minWidth: 100, }, { id: 'rating', header: 'Rating', accessorKey: 'rating', sortable: true, align: 'center', cell: (value) => `⭐ ${value}`, minWidth: 100, }, ]; export function TableSortable() { return ( <Table data={products} columns={columns} pageSize={4} searchPlaceholder='Search products...' /> ); } ``` #### Custom Cell Rendering **Example:** Table with custom cell renderers and formatting ```tsx // components/demo/table/table-custom-cells.tsx import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; import { Table, TableColumn } from '@/components/ui/table'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; interface Employee { id: number; name: string; email: string; department: string; salary: number; avatar?: string; joinDate: string; status: 'Active' | 'On Leave' | 'Terminated'; } const employees: Employee[] = [ { id: 1, name: 'Sarah Johnson', email: 'sarah.j@company.com', department: 'Engineering', salary: 95000, avatar: 'https://avatars.githubusercontent.com/u/1?v=4', joinDate: '2022-01-15', status: 'Active', }, { id: 2, name: 'Mike Chen', email: 'mike.c@company.com', department: 'Design', salary: 78000, joinDate: '2023-03-20', status: 'Active', }, { id: 3, name: 'Emma Davis', email: 'emma.d@company.com', department: 'Marketing', salary: 65000, avatar: 'https://avatars.githubusercontent.com/u/2?v=4', joinDate: '2021-11-08', status: 'On Leave', }, { id: 4, name: 'James Wilson', email: 'james.w@company.com', department: 'Sales', salary: 72000, joinDate: '2020-09-12', status: 'Terminated', }, ]; const columns: TableColumn<Employee>[] = [ { id: 'employee', header: 'Employee', accessorKey: 'name', sortable: true, filterable: true, minWidth: 200, cell: (value, row) => ( <View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}> <Avatar size={32}> {row.avatar && <AvatarImage source={{ uri: row.avatar }} />} <AvatarFallback> {row.name .split(' ') .map((n) => n[0]) .join('')} </AvatarFallback> </Avatar> <View> <Text variant='body' style={{ fontWeight: '600' }}> {row.name} </Text> <Text variant='caption' style={{ opacity: 0.7 }}> {row.email} </Text> </View> </View> ), }, { id: 'department', header: 'Department', accessorKey: 'department', sortable: true, filterable: true, minWidth: 120, }, { id: 'salary', header: 'Salary', accessorKey: 'salary', sortable: true, align: 'right', minWidth: 120, cell: (value) => ( <Text variant='body' style={{ fontWeight: '600' }}> ${value.toLocaleString()} </Text> ), }, { id: 'joinDate', header: 'Join Date', accessorKey: 'joinDate', sortable: true, align: 'center', minWidth: 120, cell: (value) => new Date(value).toLocaleDateString(), }, { id: 'status', header: 'Status', accessorKey: 'status', sortable: true, filterable: true, align: 'center', minWidth: 120, cell: (value) => ( <Badge variant={ value === 'Active' ? 'default' : value === 'On Leave' ? 'secondary' : 'destructive' } > {value} </Badge> ), }, ]; export function TableCustomCells() { return ( <Table data={employees} columns={columns} pageSize={3} searchPlaceholder='Search employees...' /> ); } ``` #### Pagination **Example:** Table with pagination controls ```tsx // components/demo/table/table-pagination.tsx import { Table, TableColumn } from '@/components/ui/table'; import React from 'react'; interface Order { id: string; customer: string; product: string; amount: number; date: string; status: 'Pending' | 'Completed' | 'Cancelled'; } // Generate sample data const generateOrders = (count: number): Order[] => { const customers = [ 'John Doe', 'Jane Smith', 'Bob Johnson', 'Alice Brown', 'Charlie Wilson', 'Diana Ross', 'Frank Miller', 'Grace Lee', ]; const products = [ 'Laptop', 'Mouse', 'Keyboard', 'Monitor', 'Headphones', 'Webcam', 'Tablet', 'Phone', ]; const statuses: Order['status'][] = ['Pending', 'Completed', 'Cancelled']; return Array.from({ length: count }, (_, i) => ({ id: `ORD-${String(i + 1).padStart(4, '0')}`, customer: customers[i % customers.length], product: products[i % products.length], amount: Math.floor(Math.random() * 1000) + 50, date: new Date(Date.now() - Math.random() * 90 * 24 * 60 * 60 * 1000) .toISOString() .split('T')[0], status: statuses[Math.floor(Math.random() * statuses.length)], })); }; const orders = generateOrders(50); const columns: TableColumn<Order>[] = [ { id: 'id', header: 'Order ID', accessorKey: 'id', sortable: true, filterable: true, minWidth: 120, }, { id: 'customer', header: 'Customer', accessorKey: 'customer', sortable: true, filterable: true, minWidth: 150, }, { id: 'product', header: 'Product', accessorKey: 'product', sortable: true, filterable: true, minWidth: 120, }, { id: 'amount', header: 'Amount', accessorKey: 'amount', sortable: true, align: 'right', minWidth: 100, cell: (value) => `$${value.toFixed(2)}`, }, { id: 'date', header: 'Date', accessorKey: 'date', sortable: true, align: 'center', minWidth: 120, }, { id: 'status', header: 'Status', accessorKey: 'status', sortable: true, filterable: true, align: 'center', minWidth: 120, }, ]; export function TablePagination() { return ( <Table data={orders} columns={columns} pageSize={8} searchPlaceholder='Search orders...' pagination={true} /> ); } ``` #### Search and Filter **Example:** Table with search functionality ```tsx // components/demo/table/table-search.tsx import { Table, TableColumn } from '@/components/ui/table'; import React from 'react'; interface Book { id: number; title: string; author: string; genre: string; year: number; isbn: string; pages: number; } const books: Book[] = [ { id: 1, title: 'The Great Gatsby', author: 'F. Scott Fitzgerald', genre: 'Fiction', year: 1925, isbn: '978-0-7432-7356-5', pages: 180, }, { id: 2, title: 'To Kill a Mockingbird', author: 'Harper Lee', genre: 'Fiction', year: 1960, isbn: '978-0-06-112008-4', pages: 281, }, { id: 3, title: '1984', author: 'George Orwell', genre: 'Dystopian', year: 1949, isbn: '978-0-452-28423-4', pages: 328, }, { id: 4, title: 'Pride and Prejudice', author: 'Jane Austen', genre: 'Romance', year: 1813, isbn: '978-0-14-143951-8', pages: 432, }, { id: 5, title: 'The Catcher in the Rye', author: 'J.D. Salinger', genre: 'Fiction', year: 1951, isbn: '978-0-316-76948-0', pages: 277, }, { id: 6, title: 'Lord of the Flies', author: 'William Golding', genre: 'Fiction', year: 1954, isbn: '978-0-571-05686-2', pages: 224, }, { id: 7, title: 'The Hobbit', author: 'J.R.R. Tolkien', genre: 'Fantasy', year: 1937, isbn: '978-0-547-92822-7', pages: 366, }, { id: 8, title: "Harry Potter and the Sorcerer's Stone", author: 'J.K. Rowling', genre: 'Fantasy', year: 1997, isbn: '978-0-439-70818-8', pages: 309, }, { id: 9, title: 'The Da Vinci Code', author: 'Dan Brown', genre: 'Mystery', year: 2003, isbn: '978-0-307-47427-5', pages: 689, }, { id: 10, title: 'Brave New World', author: 'Aldous Huxley', genre: 'Science Fiction', year: 1932, isbn: '978-0-06-085052-4', pages: 268, }, ]; const columns: TableColumn<Book>[] = [ { id: 'title', header: 'Title', accessorKey: 'title', sortable: true, filterable: true, minWidth: 200, }, { id: 'author', header: 'Author', accessorKey: 'author', sortable: true, filterable: true, minWidth: 150, }, { id: 'genre', header: 'Genre', accessorKey: 'genre', sortable: true, filterable: true, minWidth: 120, }, { id: 'year', header: 'Year', accessorKey: 'year', sortable: true, align: 'center', minWidth: 80, }, { id: 'isbn', header: 'ISBN', accessorKey: 'isbn', filterable: true, minWidth: 150, }, { id: 'pages', header: 'Pages', accessorKey: 'pages', sortable: true, align: 'right', minWidth: 80, }, ]; export function TableSearch() { return ( <Table data={books} columns={columns} pageSize={6} searchPlaceholder='Search books by title, author, genre, or ISBN...' searchable={true} filterable={true} /> ); } ``` #### Loading State **Example:** Table showing loading state ```tsx // components/demo/table/table-loading.tsx import { Button } from '@/components/ui/button'; import { Table, TableColumn } from '@/components/ui/table'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; interface ApiData { id: number; name: string; value: number; category: string; } const mockData: ApiData[] = [ { id: 1, name: 'Item A', value: 100, category: 'Type 1' }, { id: 2, name: 'Item B', value: 250, category: 'Type 2' }, { id: 3, name: 'Item C', value: 175, category: 'Type 1' }, { id: 4, name: 'Item D', value: 320, category: 'Type 3' }, ]; const columns: TableColumn<ApiData>[] = [ { id: 'name', header: 'Name', accessorKey: 'name', sortable: true, filterable: true, }, { id: 'value', header: 'Value', accessorKey: 'value', sortable: true, align: 'right', }, { id: 'category', header: 'Category', accessorKey: 'category', sortable: true, filterable: true, }, ]; export function TableLoading() { const [loading, setLoading] = useState(false); const [data, setData] = useState<ApiData[]>([]); const simulateLoading = () => { setLoading(true); setData([]); // Simulate API call setTimeout(() => { setData(mockData); setLoading(false); }, 2000); }; const clearData = () => { setData([]); setLoading(false); }; return ( <View style={{ gap: 16 }}> <View style={{ flexDirection: 'row', gap: 12 }}> <Button onPress={simulateLoading} disabled={loading}> Load Data </Button> <Button variant='outline' onPress={clearData} disabled={loading}> Clear Data </Button> </View> <Table data={data} columns={columns} loading={loading} emptyMessage="Click 'Load Data' to fetch some data" pageSize={5} /> </View> ); } ``` ## API Reference ### Table The main table component that renders data in a structured format. | Prop | Type | Default | Description | | ------------------- | --------------------------------- | --------------------- | ------------------------------------------------- | | `data` | `T[]` | - | Array of data objects to display in the table. | | `columns` | `TableColumn<T>[]` | - | Array of column definitions. | | `pagination` | `boolean` | `true` | Whether to enable pagination. | | `pageSize` | `number` | `10` | Number of rows per page. | | `searchable` | `boolean` | `true` | Whether to show the search bar. | | `searchPlaceholder` | `string` | `'Search...'` | Placeholder text for the search input. | | `loading` | `boolean` | `false` | Whether to show loading state. | | `emptyMessage` | `string` | `'No data available'` | Message to show when no data is available. | | `style` | `ViewStyle` | - | Additional styles for the table container. | | `headerStyle` | `ViewStyle` | - | Additional styles for the header row. | | `rowStyle` | `ViewStyle` | - | Additional styles for data rows. | | `cellStyle` | `ViewStyle` | - | Additional styles for table cells. | | `onRowPress` | `(row: T, index: number) => void` | - | Callback when a row is pressed. | | `sortable` | `boolean` | `true` | Whether to enable global sorting functionality. | | `filterable` | `boolean` | `true` | Whether to enable global filtering functionality. | ### TableColumn Configuration object for table columns. | Prop | Type | Default | Description | | ------------- | ----------------------------------------- | -------- | -------------------------------------------------- | | `id` | `string` | - | Unique identifier for the column. | | `header` | `string` | - | Text to display in the column header. | | `accessorKey` | `string` | - | Key to access data from the row object. | | `sortable` | `boolean` | `false` | Whether this column can be sorted. | | `filterable` | `boolean` | `false` | Whether this column is included in search filters. | | `width` | `number \| string` | - | Fixed width for the column. | | `minWidth` | `number` | `100` | Minimum width for the column. | | `cell` | `(value: any, row: T) => React.ReactNode` | - | Custom cell renderer function. | | `headerCell` | `() => React.ReactNode` | - | Custom header cell renderer function. | | `align` | `'left' \| 'center' \| 'right'` | `'left'` | Text alignment for the column. | ## Features ### Sorting - Click column headers to sort (supports ascending, descending, and no sort) - Visual indicators show current sort state - Multiple data types supported (string, number, date) ### Filtering - Global search across all filterable columns - Case-insensitive search - Real-time filtering as you type ### Pagination - Configurable page size - Navigation controls (first, previous, next, last) - Page information display ### Responsive Design - Horizontal scrolling for wide tables - Flexible column widths - Mobile-friendly touch interactions ### Accessibility The Table component is built with accessibility in mind: - Proper semantic structure with TouchableOpacity for interactive elements - Screen reader support for sort states and pagination - High contrast colors for better visibility - Keyboard navigation support where applicable - Clear loading and empty states ## Performance For large datasets, consider: - Implementing server-side pagination - Using React.memo for custom cell components - Virtualizing rows for very large datasets - Debouncing search input for better performance <!-- ---------------------------------------------------------------------- --> # Tabs > A set of layered sections of content—known as tab panels—that are displayed one at a time. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/tabs - Markdown: https://ui.ahmedbna.com/docs/components/tabs.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/tabs.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/tabs.json - Install: `npx bna-ui add tabs` - npm dependencies: `expo-haptics`, `react-native-gesture-handler`, `react-native-reanimated`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `useHaptics`, `globals`, `text`, `view` - Preview recording: https://demo.ahmedbna.com/0299-tabs-demo.MP4 --- **Example:** A basic tabs component with multiple panels ```tsx // components/demo/tabs/tabs-demo.tsx import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function TabsDemo() { return ( <Tabs defaultValue='account' style={{ width: 400 }}> <TabsList> <TabsTrigger value='account'>Account</TabsTrigger> <TabsTrigger value='followers'>Followers</TabsTrigger> <TabsTrigger value='following'>Following</TabsTrigger> <TabsTrigger value='password'>Password</TabsTrigger> <TabsTrigger value='settings'>Settings</TabsTrigger> <TabsTrigger value='more'>More</TabsTrigger> </TabsList> <TabsContent value='account'> <View style={{ paddingHorizontal: 16 }}> <Text variant='title' style={{ marginBottom: 8 }}> Account Settings </Text> <Text variant='body'> Manage your account information and preferences here. </Text> </View> </TabsContent> <TabsContent value='followers'> <View style={{ paddingHorizontal: 16 }}> <Text variant='title' style={{ marginBottom: 8 }}> Followers </Text> <Text variant='body'> Manage your followers information and preferences here. </Text> </View> </TabsContent> <TabsContent value='following'> <View style={{ paddingHorizontal: 16 }}> <Text variant='title' style={{ marginBottom: 8 }}> Following </Text> <Text variant='body'> Manage your following information and preferences here. </Text> </View> </TabsContent> <TabsContent value='password'> <View style={{ paddingHorizontal: 16 }}> <Text variant='title' style={{ marginBottom: 8 }}> Password Settings </Text> <Text variant='body'> Change your password and security settings preferences here. </Text> </View> </TabsContent> <TabsContent value='settings'> <View style={{ paddingHorizontal: 16 }}> <Text variant='title' style={{ marginBottom: 8 }}> General Settings </Text> <Text variant='body'> Configure your application preferences and options. </Text> </View> </TabsContent> <TabsContent value='more'> <View style={{ paddingHorizontal: 16 }}> <Text variant='title' style={{ marginBottom: 8 }}> More </Text> <Text variant='body'> Configure your application preferences and options. </Text> </View> </TabsContent> </Tabs> ); } ``` ## Installation ### CLI ```bash npx bna-ui add tabs ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install react-native ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/tabs.tsx import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { useHaptics } from '@/hooks/useHaptics'; import { BORDER_RADIUS, CORNERS, FONT_SIZE, HEIGHT } from '@/theme/globals'; import React, { createContext, useCallback, useContext, useEffect, useRef, useState, } from 'react'; import { ScrollView, TextStyle, TouchableOpacity, useWindowDimensions, ViewStyle, } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { Extrapolation, interpolate, runOnJS, useAnimatedStyle, useSharedValue, withTiming, } from 'react-native-reanimated'; // Types interface TabsContextType { activeTab: string; setActiveTab: (value: string) => void; orientation: 'horizontal' | 'vertical'; tabValues: string[]; registerTab: (value: string) => void; unregisterTab: (value: string) => void; enableSwipe?: boolean; navigateToAdjacentTab?: (direction: 'next' | 'prev') => void; contentMap: React.MutableRefObject<Record<string, React.ReactNode>>; haptic?: boolean; } interface TabsProps { children: React.ReactNode; defaultValue?: string; value?: string; onValueChange?: (value: string) => void; orientation?: 'horizontal' | 'vertical'; style?: ViewStyle; enableSwipe?: boolean; haptic?: boolean; } interface TabsListProps { children: React.ReactNode; style?: ViewStyle; } interface TabsTriggerProps { children: React.ReactNode; value: string; disabled?: boolean; style?: ViewStyle; textStyle?: TextStyle; } interface TabsContentProps { children: React.ReactNode; value: string; style?: ViewStyle; } // Context const TabsContext = createContext<TabsContextType | undefined>(undefined); const useTabsContext = () => { const context = useContext(TabsContext); if (!context) { throw new Error('Tabs components must be used within a Tabs provider'); } return context; }; export function Tabs({ children, defaultValue = '', value, onValueChange, orientation = 'horizontal', style, enableSwipe = true, haptic = true, }: TabsProps) { const feedback = useHaptics(haptic); const [internalActiveTab, setInternalActiveTab] = useState(defaultValue); const [tabValues, setTabValues] = useState<string[]>([]); // Per-instance carousel content cache — must live here (not module scope) // so two mounted Tabs never share/corrupt each other's content. const contentMap = useRef<Record<string, React.ReactNode>>({}); // Determine if we're in controlled or uncontrolled mode const isControlled = value !== undefined; const activeTab = isControlled ? value : internalActiveTab; // Update internal state when value prop changes (controlled mode) useEffect(() => { if (isControlled && value !== internalActiveTab) { setInternalActiveTab(value); } }, [value, isControlled, internalActiveTab]); const setActiveTab = (newValue: string) => { if (!isControlled) { // Uncontrolled mode: update internal state setInternalActiveTab(newValue); } // Call onValueChange callback if provided (works in both controlled and uncontrolled modes) if (onValueChange) { onValueChange(newValue); } }; const registerTab = useCallback((tabValue: string) => { setTabValues((prev) => { if (!prev.includes(tabValue)) { return [...prev, tabValue]; } return prev; }); }, []); const unregisterTab = useCallback((tabValue: string) => { setTabValues((prev) => prev.filter((val) => val !== tabValue)); }, []); const navigateToAdjacentTab = useCallback( (direction: 'next' | 'prev') => { const currentIndex = tabValues.indexOf(activeTab); if (currentIndex === -1) return; let nextIndex; if (direction === 'next') { nextIndex = currentIndex + 1; if (nextIndex >= tabValues.length) nextIndex = 0; // Loop to first } else { nextIndex = currentIndex - 1; if (nextIndex < 0) nextIndex = tabValues.length - 1; // Loop to last } const nextTab = tabValues[nextIndex]; if (nextTab) { // Fires here rather than in the pan gesture's onEnd, which is a worklet // on the UI thread — this runs on JS via the existing runOnJS hop. It // is also not in setActiveTab, which programmatic/controlled updates // also go through. feedback('selection'); setActiveTab(nextTab); } }, [tabValues, activeTab, setActiveTab, feedback] ); return ( <TabsContext.Provider value={{ activeTab, setActiveTab, orientation, tabValues, registerTab, unregisterTab, enableSwipe, navigateToAdjacentTab, contentMap, haptic, }} > <View style={[ { flexDirection: orientation === 'horizontal' ? 'column' : 'row', }, style, ]} > {children} </View> </TabsContext.Provider> ); } // Add this after the existing interfaces interface CarouselTabContentProps { children: React.ReactNode; value: string; style?: ViewStyle; } function CarouselTabContent({ children, value, style, }: CarouselTabContentProps) { const { activeTab, navigateToAdjacentTab, tabValues, contentMap } = useTabsContext(); // Store this content in the per-instance map (mutation during render, // matching the ref's intended "always current on next read" semantics — // must not trigger a re-render on write). contentMap.current[value] = children; // Only render the carousel container for the active tab if (activeTab !== value) { return null; } return ( <CarouselContainer activeTab={activeTab} tabValues={tabValues} onSwipe={navigateToAdjacentTab!} contentMap={contentMap} style={style} /> ); } function CarouselContainer({ activeTab, tabValues, onSwipe, contentMap, style, }: { activeTab: string; tabValues: string[]; onSwipe: (direction: 'next' | 'prev') => void; contentMap: React.MutableRefObject<Record<string, React.ReactNode>>; style?: ViewStyle; }) { const { width: screenWidth } = useWindowDimensions(); const translateX = useSharedValue(0); const isGestureActive = useSharedValue(false); const currentIndex = tabValues.indexOf(activeTab); // Reset translation when active tab changes (only if not during gesture) useEffect(() => { if (!isGestureActive.value) { translateX.value = withTiming(0, { duration: 300 }); } }, [activeTab]); const panGesture = Gesture.Pan() .onBegin(() => { isGestureActive.value = true; }) .onUpdate((event) => { translateX.value = event.translationX; }) .onEnd((event) => { isGestureActive.value = false; const threshold = screenWidth * 0.15; // Lower threshold for easier swiping const velocity = Math.abs(event.velocityX); const translation = event.translationX; // Determine if we should change tabs based on distance or velocity const shouldChangeTab = Math.abs(translation) > threshold || velocity > 500; if (shouldChangeTab) { if (translation > 0 && currentIndex > 0) { // Swiped right - go to previous tab runOnJS(onSwipe)('prev'); } else if (translation < 0 && currentIndex < tabValues.length - 1) { // Swiped left - go to next tab runOnJS(onSwipe)('next'); } } // No snapping back - let the tab change handle the reset }); const getPreviousTab = () => { const prevIndex = currentIndex - 1; return prevIndex >= 0 ? tabValues[prevIndex] : null; }; const getNextTab = () => { const nextIndex = currentIndex + 1; return nextIndex < tabValues.length ? tabValues[nextIndex] : null; }; const previousTab = getPreviousTab(); const nextTab = getNextTab(); const containerStyle = useAnimatedStyle(() => ({ transform: [{ translateX: translateX.value }], })); const previousStyle = useAnimatedStyle(() => { const opacity = interpolate( translateX.value, [0, screenWidth * 0.5], [0, 1], Extrapolation.CLAMP ); return { transform: [{ translateX: translateX.value - screenWidth }], opacity: previousTab ? opacity : 0, }; }); const nextStyle = useAnimatedStyle(() => { const opacity = interpolate( translateX.value, [-screenWidth * 0.5, 0], [1, 0], Extrapolation.CLAMP ); return { transform: [{ translateX: translateX.value + screenWidth }], opacity: nextTab ? opacity : 0, }; }); return ( <GestureDetector gesture={panGesture}> <View style={{ overflow: 'hidden' }}> {/* Previous content */} {previousTab && ( <Animated.View style={[ { position: 'absolute', width: screenWidth, paddingTop: 16, }, style, previousStyle, ]} pointerEvents='none' > {contentMap.current[previousTab]} </Animated.View> )} {/* Current content */} <Animated.View style={[ { paddingTop: 16, }, style, containerStyle, ]} > {contentMap.current[activeTab]} </Animated.View> {/* Next content */} {nextTab && ( <Animated.View style={[ { position: 'absolute', width: screenWidth, paddingTop: 16, }, style, nextStyle, ]} pointerEvents='none' > {contentMap.current[nextTab]} </Animated.View> )} </View> </GestureDetector> ); } export function TabsList({ children, style }: TabsListProps) { const { orientation } = useTabsContext(); const backgroundColor = useColor('muted'); return ( <View accessibilityRole='tablist' style={[ { padding: 6, backgroundColor, borderRadius: orientation === 'horizontal' ? CORNERS : BORDER_RADIUS, }, style, ]} > <ScrollView horizontal={orientation === 'horizontal'} showsHorizontalScrollIndicator={false} showsVerticalScrollIndicator={false} contentContainerStyle={{ flexDirection: orientation === 'horizontal' ? 'row' : 'column', alignItems: 'center', }} > {children} </ScrollView> </View> ); } export function TabsTrigger({ children, value, disabled = false, style, textStyle, }: TabsTriggerProps) { const { activeTab, setActiveTab, orientation, registerTab, unregisterTab, haptic, } = useTabsContext(); const isActive = activeTab === value; const feedback = useHaptics(haptic ?? true); // Register/unregister tab for swipe navigation useEffect(() => { registerTab(value); return () => unregisterTab(value); }, [value, registerTab, unregisterTab]); const primaryColor = useColor('primary'); const mutedForegroundColor = useColor('mutedForeground'); const backgroundColor = useColor('background'); const handlePress = () => { if (!disabled) { if (!isActive) feedback('selection'); setActiveTab(value); } }; const triggerStyle: ViewStyle = { paddingHorizontal: 12, paddingVertical: orientation === 'vertical' ? 8 : undefined, borderRadius: CORNERS, alignItems: 'center', justifyContent: 'center', minHeight: HEIGHT - 8, backgroundColor: isActive ? backgroundColor : 'transparent', opacity: disabled ? 0.5 : 1, flex: orientation === 'horizontal' ? 1 : undefined, marginBottom: orientation === 'vertical' ? 4 : 0, ...style, }; const triggerTextStyle: TextStyle = { fontSize: FONT_SIZE, fontWeight: '500', color: isActive ? primaryColor : mutedForegroundColor, textAlign: 'center', ...textStyle, }; return ( <TouchableOpacity style={triggerStyle} onPress={handlePress} disabled={disabled} activeOpacity={0.8} accessibilityRole='tab' accessibilityState={{ selected: isActive, disabled }} > {typeof children === 'string' ? ( <Text style={triggerTextStyle}>{children}</Text> ) : ( children )} </TouchableOpacity> ); } export function TabsContent({ children, value, style }: TabsContentProps) { const { activeTab, enableSwipe, orientation, navigateToAdjacentTab, tabValues, } = useTabsContext(); const isActive = activeTab === value; // For carousel mode, we need to render all content but only show active one if (enableSwipe && orientation === 'horizontal' && navigateToAdjacentTab) { return ( <CarouselTabContent value={value} style={style}> {children} </CarouselTabContent> ); } // Regular mode - only render active content if (!isActive) { return null; } return ( <View style={[ { paddingTop: 16, }, style, ]} > {children} </View> ); } ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; ``` ```tsx <Tabs defaultValue='tab1'> <TabsList> <TabsTrigger value='tab1'>Tab 1</TabsTrigger> <TabsTrigger value='tab2'>Tab 2</TabsTrigger> <TabsTrigger value='tab3'>Tab 3</TabsTrigger> </TabsList> <TabsContent value='tab1'> <Text>Content for Tab 1</Text> </TabsContent> <TabsContent value='tab2'> <Text>Content for Tab 2</Text> </TabsContent> <TabsContent value='tab3'> <Text>Content for Tab 3</Text> </TabsContent> </Tabs> ``` ## Examples #### Default **Example:** A basic tabs component with multiple panels ```tsx // components/demo/tabs/tabs-demo.tsx import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function TabsDemo() { return ( <Tabs defaultValue='account' style={{ width: 400 }}> <TabsList> <TabsTrigger value='account'>Account</TabsTrigger> <TabsTrigger value='followers'>Followers</TabsTrigger> <TabsTrigger value='following'>Following</TabsTrigger> <TabsTrigger value='password'>Password</TabsTrigger> <TabsTrigger value='settings'>Settings</TabsTrigger> <TabsTrigger value='more'>More</TabsTrigger> </TabsList> <TabsContent value='account'> <View style={{ paddingHorizontal: 16 }}> <Text variant='title' style={{ marginBottom: 8 }}> Account Settings </Text> <Text variant='body'> Manage your account information and preferences here. </Text> </View> </TabsContent> <TabsContent value='followers'> <View style={{ paddingHorizontal: 16 }}> <Text variant='title' style={{ marginBottom: 8 }}> Followers </Text> <Text variant='body'> Manage your followers information and preferences here. </Text> </View> </TabsContent> <TabsContent value='following'> <View style={{ paddingHorizontal: 16 }}> <Text variant='title' style={{ marginBottom: 8 }}> Following </Text> <Text variant='body'> Manage your following information and preferences here. </Text> </View> </TabsContent> <TabsContent value='password'> <View style={{ paddingHorizontal: 16 }}> <Text variant='title' style={{ marginBottom: 8 }}> Password Settings </Text> <Text variant='body'> Change your password and security settings preferences here. </Text> </View> </TabsContent> <TabsContent value='settings'> <View style={{ paddingHorizontal: 16 }}> <Text variant='title' style={{ marginBottom: 8 }}> General Settings </Text> <Text variant='body'> Configure your application preferences and options. </Text> </View> </TabsContent> <TabsContent value='more'> <View style={{ paddingHorizontal: 16 }}> <Text variant='title' style={{ marginBottom: 8 }}> More </Text> <Text variant='body'> Configure your application preferences and options. </Text> </View> </TabsContent> </Tabs> ); } ``` #### Vertical Orientation **Example:** Tabs arranged in vertical orientation ```tsx // components/demo/tabs/tabs-vertical.tsx import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function TabsVertical() { return ( <Tabs defaultValue='profile' orientation='vertical'> <TabsList> <TabsTrigger value='profile'>🧑‍💼</TabsTrigger> <TabsTrigger value='security'>🫆</TabsTrigger> <TabsTrigger value='notifications'>🔔</TabsTrigger> <TabsTrigger value='billing'>💰</TabsTrigger> </TabsList> <TabsContent value='profile' style={{ flex: 1 }}> <View style={{ marginHorizontal: 16 }}> <Text variant='title' style={{ marginBottom: 8 }}> Profile Information </Text> <Text variant='body'> Update your personal information and profile picture. </Text> </View> </TabsContent> <TabsContent value='security' style={{ flex: 1 }}> <View style={{ marginHorizontal: 16 }}> <Text variant='title' style={{ marginBottom: 8 }}> Security Settings </Text> <Text variant='body'> Manage two-factor authentication and login security. </Text> </View> </TabsContent> <TabsContent value='notifications' style={{ flex: 1 }}> <View style={{ marginHorizontal: 16 }}> <Text variant='title' style={{ marginBottom: 8 }}> Notification Preferences </Text> <Text variant='body'> Configure how and when you receive notifications. </Text> </View> </TabsContent> <TabsContent value='billing' style={{ flex: 1 }}> <View style={{ marginHorizontal: 16 }}> <Text variant='title' style={{ marginBottom: 8 }}> Billing & Subscription </Text> <Text variant='body'> Manage your subscription and payment methods. </Text> </View> </TabsContent> </Tabs> ); } ``` #### Disabled Tabs **Example:** Tabs with disabled states ```tsx // components/demo/tabs/tabs-disabled.tsx import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function TabsDisabled() { return ( <Tabs defaultValue='available' style={{ width: 400 }}> <TabsList> <TabsTrigger value='available'>Available</TabsTrigger> <TabsTrigger value='pending'>Pending</TabsTrigger> <TabsTrigger value='premium' disabled> Premium </TabsTrigger> <TabsTrigger value='enterprise' disabled> Enterprise </TabsTrigger> </TabsList> <TabsContent value='available'> <View style={{ padding: 16 }}> <Text variant='title' style={{ marginBottom: 8 }}> Available Features </Text> <Text variant='body'> These features are currently available to you. </Text> </View> </TabsContent> <TabsContent value='pending'> <View style={{ padding: 16 }}> <Text variant='title' style={{ marginBottom: 8 }}> Pending Features </Text> <Text variant='body'> These features are being processed and will be available soon. </Text> </View> </TabsContent> <TabsContent value='premium'> <View style={{ padding: 16 }}> <Text variant='title' style={{ marginBottom: 8 }}> Premium Features </Text> <Text variant='body'>Upgrade to access premium features.</Text> </View> </TabsContent> <TabsContent value='enterprise'> <View style={{ padding: 16 }}> <Text variant='title' style={{ marginBottom: 8 }}> Enterprise Features </Text> <Text variant='body'>Contact sales for enterprise features.</Text> </View> </TabsContent> </Tabs> ); } ``` #### Custom Styling **Example:** Tabs with custom colors and styling ```tsx // components/demo/tabs/tabs-styled.tsx import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useState } from 'react'; export function TabsStyled() { const [value, setValue] = useState('design'); return ( <Tabs value={value} onValueChange={setValue}> <TabsList style={{ backgroundColor: value === 'design' ? '#3b82f6' : value === 'development' ? '#10b981' : '#f59e0b', shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 4, elevation: 3, borderRadius: 8, }} > <TabsTrigger value='design' style={{ borderRadius: 8 }} textStyle={{ fontWeight: '600', color: '#3b82f6' }} > Design </TabsTrigger> <TabsTrigger value='development' style={{ borderRadius: 8 }} textStyle={{ fontWeight: '600', color: '#10b981' }} > Development </TabsTrigger> <TabsTrigger value='testing' style={{ borderRadius: 8 }} textStyle={{ fontWeight: '600', color: '#f59e0b' }} > Testing </TabsTrigger> </TabsList> <TabsContent value='design'> <View style={{ padding: 20, backgroundColor: '#eff6ff', borderRadius: 12, marginTop: 8, }} > <Text variant='title' style={{ color: '#1e40af', marginBottom: 8 }}> Design Phase </Text> <Text variant='body' style={{ color: '#1e40af' }}> Create wireframes, mockups, and design systems for your project. </Text> </View> </TabsContent> <TabsContent value='development'> <View style={{ padding: 20, backgroundColor: '#ecfdf5', borderRadius: 12, marginTop: 8, }} > <Text variant='title' style={{ color: '#047857', marginBottom: 8 }}> Development Phase </Text> <Text variant='body' style={{ color: '#047857' }}> Build and implement the features based on the design specifications. </Text> </View> </TabsContent> <TabsContent value='testing'> <View style={{ padding: 20, backgroundColor: '#fffbeb', borderRadius: 12, marginTop: 8, }} > <Text variant='title' style={{ color: '#92400e', marginBottom: 8 }}> Testing Phase </Text> <Text variant='body' style={{ color: '#92400e' }}> Perform quality assurance and user acceptance testing. </Text> </View> </TabsContent> </Tabs> ); } ``` ## API Reference ### Tabs The root container for the tabs component. | Prop | Type | Default | Description | | --------------- | ---------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `haptic` | `boolean` | `true` | Whether to trigger haptic feedback when the active tab changes, by tap or by swipe. Programmatic changes stay silent. | | `children` | `ReactNode` | - | The tabs list and content components. | | `defaultValue` | `string` | - | The value of the tab that should be active by default. | | `value` | `string` | - | The controlled active tab value. When provided, the component becomes controlled and `onValueChange` must be used to update it. | | `onValueChange` | `(value: string) => void` | - | Called whenever the active tab changes, whether by press or swipe. | | `orientation` | `'horizontal' \| 'vertical'` | `'horizontal'` | The orientation of the tabs. | | `enableSwipe` | `boolean` | `true` | Whether horizontal tabs can be swiped between, in addition to pressing a trigger. Has no effect when `orientation` is `"vertical"`. | | `style` | `ViewStyle` | - | Additional styles to apply to the container. | ### TabsList Container for the tab triggers. | Prop | Type | Description | | ---------- | ----------- | -------------------------------------------- | | `children` | `ReactNode` | The tab trigger components. | | `style` | `ViewStyle` | Additional styles to apply to the tabs list. | ### TabsTrigger The clickable tab that activates its associated content. | Prop | Type | Default | Description | | ----------- | ----------- | ------- | ----------------------------------------------- | | `children` | `ReactNode` | - | The content of the tab trigger (usually text). | | `value` | `string` | - | The unique value that identifies this tab. | | `disabled` | `boolean` | `false` | Whether the tab is disabled. | | `style` | `ViewStyle` | - | Additional styles to apply to the trigger. | | `textStyle` | `TextStyle` | - | Additional styles to apply to the trigger text. | ### TabsContent The content panel associated with a tab trigger. | Prop | Type | Description | | ---------- | ----------- | ---------------------------------------------- | | `children` | `ReactNode` | The content to display when the tab is active. | | `value` | `string` | The value that matches the associated trigger. | | `style` | `ViewStyle` | Additional styles to apply to the content. | ## Accessibility The Tabs component is built with accessibility in mind: - `TabsList` exposes `accessibilityRole="tablist"`, each `TabsTrigger` exposes `accessibilityRole="tab"` with `accessibilityState={{ selected, disabled }}` - Disabled tabs report `accessibilityState.disabled` to screen readers <!-- ---------------------------------------------------------------------- --> # Text > A themed text component with multiple variants for consistent typography across your app. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/text - Markdown: https://ui.ahmedbna.com/docs/components/text.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/text.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/text.json - Install: `npx bna-ui add text` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals` - Preview recording: https://demo.ahmedbna.com/0303-text-demo.PNG --- **Example:** Basic text component showing different variants ```tsx // components/demo/text/text-demo.tsx import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function TextDemo() { return ( <View style={{ gap: 16 }}> <Text variant='heading'>Heading Text</Text> <Text variant='title'>Title Text</Text> <Text variant='subtitle'>Subtitle Text</Text> <Text variant='body'> This is body text that demonstrates the default styling for regular content. </Text> <Text variant='caption'>Caption text for additional information</Text> <Text variant='link'>Link text with underline</Text> </View> ); } ``` ## Installation ### CLI ```bash npx bna-ui add text ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install react-native ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/text.tsx import { useColor } from '@/hooks/useColor'; import { FONT_SIZE } from '@/theme/globals'; import React, { forwardRef } from 'react'; import { Text as RNText, TextProps as RNTextProps, TextStyle, } from 'react-native'; type TextVariant = 'body' | 'title' | 'subtitle' | 'caption' | 'heading' | 'link'; interface TextProps extends RNTextProps { variant?: TextVariant; lightColor?: string; darkColor?: string; children: React.ReactNode; } const headingVariants: TextVariant[] = ['heading', 'title', 'subtitle']; export const Text = React.memo( forwardRef<RNText, TextProps>( ( { variant = 'body', lightColor, darkColor, style, children, ...props }, ref ) => { const textColor = useColor('text', { light: lightColor, dark: darkColor, }); const mutedColor = useColor('textMuted'); const defaultAccessibilityRole = headingVariants.includes(variant) ? 'header' : undefined; const getTextStyle = (): TextStyle => { const baseStyle: TextStyle = { color: textColor, }; switch (variant) { case 'heading': return { ...baseStyle, fontSize: 28, fontWeight: '700', }; case 'title': return { ...baseStyle, fontSize: 24, fontWeight: '700', }; case 'subtitle': return { ...baseStyle, fontSize: 19, fontWeight: '600', }; case 'caption': return { ...baseStyle, fontSize: FONT_SIZE, fontWeight: '400', color: mutedColor, }; case 'link': return { ...baseStyle, fontSize: FONT_SIZE, fontWeight: '500', textDecorationLine: 'underline', }; default: // 'body' return { ...baseStyle, fontSize: FONT_SIZE, fontWeight: '400', }; } }; return ( <RNText ref={ref} style={[getTextStyle(), style]} accessibilityRole={defaultAccessibilityRole} {...props} > {children} </RNText> ); } ) ); Text.displayName = 'Text'; ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { Text } from '@/components/ui/text'; ``` ```tsx <Text variant="body">This is body text</Text> <Text variant="heading">This is a heading</Text> <Text variant="caption">This is caption text</Text> ``` ## Examples #### Default **Example:** Basic text component showing different variants ```tsx // components/demo/text/text-demo.tsx import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function TextDemo() { return ( <View style={{ gap: 16 }}> <Text variant='heading'>Heading Text</Text> <Text variant='title'>Title Text</Text> <Text variant='subtitle'>Subtitle Text</Text> <Text variant='body'> This is body text that demonstrates the default styling for regular content. </Text> <Text variant='caption'>Caption text for additional information</Text> <Text variant='link'>Link text with underline</Text> </View> ); } ``` #### Typography Scale **Example:** All text variants showing the typography hierarchy ```tsx // components/demo/text/text-variants.tsx import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function TextVariants() { return ( <View style={{ gap: 20 }}> <View> <Text variant='caption' style={{ marginBottom: 4 }}> HEADING (28px, weight 700) </Text> <Text variant='heading'> The quick brown fox jumps over the lazy dog </Text> </View> <View> <Text variant='caption' style={{ marginBottom: 4 }}> TITLE (24px, weight 700) </Text> <Text variant='title'>The quick brown fox jumps over the lazy dog</Text> </View> <View> <Text variant='caption' style={{ marginBottom: 4 }}> SUBTITLE (19px, weight 600) </Text> <Text variant='subtitle'> The quick brown fox jumps over the lazy dog </Text> </View> <View> <Text variant='caption' style={{ marginBottom: 4 }}> BODY (16px, weight 400) </Text> <Text variant='body'> The quick brown fox jumps over the lazy dog. This is the default text variant used for body content and regular paragraphs. </Text> </View> <View> <Text variant='caption' style={{ marginBottom: 4 }}> CAPTION (16px, weight 400, muted) </Text> <Text variant='caption'> The quick brown fox jumps over the lazy dog </Text> </View> <View> <Text variant='caption' style={{ marginBottom: 4 }}> LINK (16px, weight 500, underlined) </Text> <Text variant='link'>The quick brown fox jumps over the lazy dog</Text> </View> </View> ); } ``` #### Custom Colors **Example:** Text with custom light and dark mode colors ```tsx // components/demo/text/text-colors.tsx import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import React from 'react'; export function TextColors() { return ( <View style={{ gap: 16 }}> <Text variant='subtitle' style={{ marginBottom: 8 }}> Custom Color Examples </Text> <Text variant='body' lightColor='#3b82f6' darkColor='#60a5fa'> This text uses custom blue colors for light and dark themes </Text> <Text variant='body' lightColor='#10b981' darkColor='#34d399'> This text uses custom green colors for light and dark themes </Text> <Text variant='body' lightColor='#f59e0b' darkColor='#fbbf24'> This text uses custom amber colors for light and dark themes </Text> <Text variant='body' lightColor='#ef4444' darkColor='#f87171'> This text uses custom red colors for light and dark themes </Text> <Text variant='body' lightColor='#8b5cf6' darkColor='#a78bfa'> This text uses custom purple colors for light and dark themes </Text> <View style={{ marginTop: 16 }}> <Text variant='caption'> Note: These colors automatically adapt based on the current theme (light/dark mode) </Text> </View> </View> ); } ``` ## API Reference ### Text A flexible text component that supports multiple variants and theming. | Prop | Type | Default | Description | | ------------ | --------------------------------------------------------------------- | -------- | ------------------------------------------------ | | `variant` | `'body' \| 'title' \| 'subtitle' \| 'caption' \| 'heading' \| 'link'` | `'body'` | The text variant that determines styling. | | `lightColor` | `string` | - | Custom color for light theme. | | `darkColor` | `string` | - | Custom color for dark theme. | | `children` | `ReactNode` | - | The text content to display. | | `style` | `TextStyle` | - | Additional styles to apply to the text. | | `...props` | `TextProps` | - | All other React Native Text props are supported. | ## Variants ### Heading Large, bold text for main headings (28px, weight 700). ### Title Medium-large text for section titles (24px, weight 700). ### Subtitle Medium text for subsections (19px, weight 600). ### Body Standard text for content (default size, weight 400). ### Caption Small, muted text for captions and secondary info (default size, weight 400, muted color). ### Link Text styled as a clickable link (default size, weight 500, underlined). ## Theming The Text component automatically adapts to your app's theme: - Uses theme colors for text and muted text - Supports light and dark mode variants - Allows custom color overrides via `lightColor` and `darkColor` props - Integrates with the `useColor` hook ## Accessibility The Text component is built with accessibility in mind: - Inherits all React Native Text accessibility features - Supports dynamic text sizing for users with vision impairments - Maintains proper contrast ratios with theme colors - Works with screen readers and other assistive technologies - Supports semantic roles via standard React Native Text props <!-- ---------------------------------------------------------------------- --> # Toast > A succinct message that is displayed temporarily with Dynamic Island animation inspired by iOS. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/toast - Markdown: https://ui.ahmedbna.com/docs/components/toast.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/toast.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/toast.json - Install: `npx bna-ui add toast` - npm dependencies: `lucide-react-native`, `react-native-gesture-handler`, `react-native-reanimated`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `text` - Preview recording: https://demo.ahmedbna.com/0306-toast-demo.MP4 --- **Example:** A basic toast notification with title and description ```tsx // components/demo/toast/toast-demo.tsx import { Button } from '@/components/ui/button'; import { useToast } from '@/components/ui/toast'; import React from 'react'; export function ToastDemo() { const { toast } = useToast(); const showToast = () => { toast({ title: 'Toast Notification', description: 'This is a basic toast notification with title and description.', variant: 'default', }); }; return <Button onPress={showToast}>Show Toast</Button>; } ``` ## Installation ### CLI ```bash npx bna-ui add toast ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install react-native-gesture-handler react-native-reanimated lucide-react-native ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/toast.tsx import { Text } from '@/components/ui/text'; import { AlertCircle, Check, Info, X } from 'lucide-react-native'; import React, { createContext, useCallback, useContext, useEffect, useState, } from 'react'; import { AccessibilityInfo, Dimensions, Platform, TouchableOpacity, View, ViewStyle, } from 'react-native'; import { Gesture, GestureDetector, GestureHandlerRootView, } from 'react-native-gesture-handler'; import Animated, { runOnJS, useAnimatedStyle, useSharedValue, withDelay, withSpring, withTiming, } from 'react-native-reanimated'; export type ToastVariant = 'default' | 'success' | 'error' | 'warning' | 'info'; export interface ToastData { id: string; title?: string; description?: string; variant?: ToastVariant; duration?: number; action?: { label: string; onPress: () => void; }; } interface ToastProps extends ToastData { onDismiss: (id: string) => void; index: number; } const { width: screenWidth } = Dimensions.get('window'); const DYNAMIC_ISLAND_HEIGHT = 37; const EXPANDED_HEIGHT = 85; const TOAST_MARGIN = 8; const DYNAMIC_ISLAND_WIDTH = 126; const EXPANDED_WIDTH = screenWidth - 32; // Reanimated spring configuration const SPRING_CONFIG = { stiffness: 120, damping: 8, }; export function Toast({ id, title, description, variant = 'default', onDismiss, index, action, }: ToastProps) { const [isExpanded, setIsExpanded] = useState(false); const [reduceMotion, setReduceMotion] = useState(false); useEffect(() => { AccessibilityInfo.isReduceMotionEnabled().then(setReduceMotion); const subscription = AccessibilityInfo.addEventListener( 'reduceMotionChanged', setReduceMotion ); return () => subscription.remove(); }, []); // Reanimated shared values const translateY = useSharedValue(-100); const translateX = useSharedValue(0); const opacity = useSharedValue(0); const scale = useSharedValue(0.8); const width = useSharedValue(DYNAMIC_ISLAND_WIDTH); const height = useSharedValue(DYNAMIC_ISLAND_HEIGHT); const borderRadius = useSharedValue(18.5); const contentOpacity = useSharedValue(0); // Dynamic Island colors (dark theme optimized) const backgroundColor = '#1C1C1E'; // iOS Dynamic Island background const mutedTextColor = '#8E8E93'; // iOS secondary text color useEffect(() => { const hasContentToShow = Boolean(title || description || action); if (hasContentToShow) { // If there's content, start directly with expanded state width.value = EXPANDED_WIDTH; height.value = EXPANDED_HEIGHT; borderRadius.value = 20; setIsExpanded(true); if (reduceMotion) { translateY.value = 0; opacity.value = 1; scale.value = 1; contentOpacity.value = 1; } else { // Animate in expanded toast translateY.value = withSpring(0, SPRING_CONFIG); opacity.value = withTiming(1, { duration: 300 }); scale.value = withSpring(1, SPRING_CONFIG); // CORRECTED LINE: Use withDelay to wrap withTiming contentOpacity.value = withDelay(100, withTiming(1, { duration: 300 })); } } else { // If no content, show compact Dynamic Island with icon only setIsExpanded(false); if (reduceMotion) { translateY.value = 0; opacity.value = 1; scale.value = 1; } else { // Animate in compact toast translateY.value = withSpring(0, SPRING_CONFIG); opacity.value = withTiming(1, { duration: 200 }); scale.value = withSpring(1, SPRING_CONFIG); } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [reduceMotion]); // Re-run if the reduced-motion setting resolves after mount const getVariantColor = () => { switch (variant) { case 'success': return '#30D158'; // iOS green case 'error': return '#FF453A'; // iOS red case 'warning': return '#FF9F0A'; // iOS orange case 'info': return '#007AFF'; // iOS blue default: return '#8E8E93'; // iOS gray } }; const getIcon = () => { const iconProps = { size: 16, color: getVariantColor() }; switch (variant) { case 'success': return <Check {...iconProps} />; case 'error': return <X {...iconProps} />; case 'warning': return <AlertCircle {...iconProps} />; case 'info': return <Info {...iconProps} />; default: return null; } }; const dismiss = useCallback(() => { if (reduceMotion) { onDismiss(id); return; } // This function will be called from the UI thread const onDismissAction = () => { 'worklet'; runOnJS(onDismiss)(id); }; translateY.value = withSpring(-100, SPRING_CONFIG); opacity.value = withTiming(0, { duration: 250 }, (finished) => { if (finished) { onDismissAction(); } }); scale.value = withSpring(0.8, SPRING_CONFIG); }, [id, onDismiss, reduceMotion]); const panGesture = Gesture.Pan() .onUpdate((event) => { // Reduced motion: swipe-to-dismiss still works (below), it just // doesn't visually track the finger. if (reduceMotion) return; translateX.value = event.translationX; }) .onEnd((event) => { const { translationX, velocityX } = event; if ( Math.abs(translationX) > screenWidth * 0.25 || Math.abs(velocityX) > 800 ) { if (reduceMotion) { runOnJS(onDismiss)(id); return; } // Dismiss action to be called from the UI thread const onDismissAction = () => { 'worklet'; runOnJS(onDismiss)(id); }; // Animate out horizontally translateX.value = withTiming( translationX > 0 ? screenWidth : -screenWidth, { duration: 250 } ); opacity.value = withTiming(0, { duration: 250 }, (finished) => { if (finished) { onDismissAction(); } }); } else if (!reduceMotion) { // Snap back with spring animation translateX.value = withSpring(0, SPRING_CONFIG); } }); const getTopPosition = () => { const statusBarHeight = Platform.OS === 'ios' ? 59 : 20; return statusBarHeight + index * (EXPANDED_HEIGHT + TOAST_MARGIN); }; // Animated styles const animatedContainerStyle = useAnimatedStyle(() => ({ opacity: opacity.value, transform: [ { translateY: translateY.value }, { translateX: translateX.value }, { scale: scale.value }, ], })); const animatedIslandStyle = useAnimatedStyle(() => ({ width: width.value, height: height.value, borderRadius: borderRadius.value, backgroundColor, justifyContent: 'center', alignItems: 'center', overflow: 'hidden', })); const animatedContentStyle = useAnimatedStyle(() => ({ opacity: contentOpacity.value, })); const toastStyle: ViewStyle = { position: 'absolute', top: getTopPosition(), alignSelf: 'center', shadowColor: '#000', shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.25, shadowRadius: 20, elevation: 10, zIndex: 1000 + index, }; return ( <GestureDetector gesture={panGesture}> <Animated.View style={[toastStyle, animatedContainerStyle]} accessible accessibilityRole='alert' accessibilityLiveRegion='polite' accessibilityLabel={[title, description].filter(Boolean).join('. ')} > <Animated.View style={animatedIslandStyle}> {/* Compact state - just icon or indicator */} {!isExpanded && ( <View style={{ justifyContent: 'center', alignItems: 'center' }}> {getIcon()} </View> )} {/* Expanded state - full content */} {isExpanded && ( <Animated.View style={[ { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, paddingHorizontal: 16, paddingVertical: 12, flexDirection: 'row', alignItems: 'center', }, animatedContentStyle, ]} > {getIcon() && ( <View style={{ marginRight: 12 }}>{getIcon()}</View> )} <View style={{ flex: 1, minWidth: 0 }}> {title && ( <Text variant='subtitle' style={{ color: '#FFFFFF', fontSize: 15, fontWeight: '600', marginBottom: description ? 2 : 0, }} numberOfLines={1} ellipsizeMode='tail' > {title} </Text> )} {description && ( <Text variant='caption' style={{ color: mutedTextColor, fontSize: 13, fontWeight: '400', }} numberOfLines={2} ellipsizeMode='tail' > {description} </Text> )} </View> {action && ( <TouchableOpacity onPress={action.onPress} style={{ marginLeft: 12, paddingHorizontal: 12, paddingVertical: 6, backgroundColor: getVariantColor(), borderRadius: 12, }} > <Text variant='caption' style={{ color: '#FFFFFF', fontSize: 12, fontWeight: '600', }} > {action.label} </Text> </TouchableOpacity> )} <TouchableOpacity onPress={dismiss} style={{ marginLeft: 8, padding: 4, borderRadius: 8 }} > <X size={14} color={mutedTextColor} /> </TouchableOpacity> </Animated.View> )} </Animated.View> </Animated.View> </GestureDetector> ); } interface ToastContextType { toast: (toast: Omit<ToastData, 'id'>) => void; success: (title: string, description?: string) => void; error: (title: string, description?: string) => void; warning: (title: string, description?: string) => void; info: (title: string, description?: string) => void; dismiss: (id: string) => void; dismissAll: () => void; } const ToastContext = createContext<ToastContextType | null>(null); interface ToastProviderProps { children: React.ReactNode; maxToasts?: number; } export function ToastProvider({ children, maxToasts = 3 }: ToastProviderProps) { const [toasts, setToasts] = useState<ToastData[]>([]); const generateId = () => Math.random().toString(36).substr(2, 9); const addToast = useCallback( (toastData: Omit<ToastData, 'id'>) => { const id = generateId(); const newToast: ToastData = { ...toastData, id, duration: toastData.duration ?? 4000, }; setToasts((prev) => { const updated = [newToast, ...prev]; return updated.slice(0, maxToasts); }); // Auto dismiss after duration if (newToast.duration && newToast.duration > 0) { setTimeout(() => { dismissToast(id); }, newToast.duration); } }, [maxToasts] ); const dismissToast = useCallback((id: string) => { setToasts((prev) => prev.filter((toast) => toast.id !== id)); }, []); const dismissAll = useCallback(() => { setToasts([]); }, []); const createVariantToast = useCallback( (variant: ToastVariant, title: string, description?: string) => { addToast({ title, description, variant, }); }, [addToast] ); const contextValue: ToastContextType = { toast: addToast, success: (title, description) => createVariantToast('success', title, description), error: (title, description) => createVariantToast('error', title, description), warning: (title, description) => createVariantToast('warning', title, description), info: (title, description) => createVariantToast('info', title, description), dismiss: dismissToast, dismissAll, }; const containerStyle: ViewStyle = { position: 'absolute', top: 0, left: 0, right: 0, zIndex: 1000, pointerEvents: 'box-none', }; return ( <ToastContext.Provider value={contextValue}> <GestureHandlerRootView style={{ flex: 1 }}> {children} <View style={containerStyle} pointerEvents='box-none'> {toasts.map((toast, index) => ( <Toast key={toast.id} {...toast} index={index} onDismiss={dismissToast} /> ))} </View> </GestureHandlerRootView> </ToastContext.Provider> ); } // Hook to use toast export function useToast() { const context = useContext(ToastContext); if (!context) { throw new Error('useToast must be used within a ToastProvider'); } return context; } ``` **3.** Update the import paths to match your project setup. **4.** Wrap your app with the ToastProvider. ```tsx import { ToastProvider } from '@/components/ui/toast'; export default function App() { return <ToastProvider>{/* Your app content */}</ToastProvider>; } ``` ## Usage ```tsx import { useToast } from '@/components/ui/toast'; ``` ```tsx function MyComponent() { const { toast } = useToast(); const showToast = () => { toast({ title: 'Success!', description: 'Your changes have been saved.', variant: 'success', }); }; return <Button onPress={showToast}>Show Toast</Button>; } ``` ## Examples #### Default **Example:** A basic toast notification with title and description ```tsx // components/demo/toast/toast-demo.tsx import { Button } from '@/components/ui/button'; import { useToast } from '@/components/ui/toast'; import React from 'react'; export function ToastDemo() { const { toast } = useToast(); const showToast = () => { toast({ title: 'Toast Notification', description: 'This is a basic toast notification with title and description.', variant: 'default', }); }; return <Button onPress={showToast}>Show Toast</Button>; } ``` #### Variants **Example:** Toast notifications with different variants (success, error, warning, info) ```tsx // components/demo/toast/toast-variants.tsx import { Button } from '@/components/ui/button'; import { useToast } from '@/components/ui/toast'; import { View } from '@/components/ui/view'; import React from 'react'; export function ToastVariants() { const { success, error, warning, info } = useToast(); return ( <View style={{ gap: 12 }}> <Button onPress={() => success('Success!', 'Your action was completed successfully.') } variant='success' > Success </Button> <Button onPress={() => error('Error!', 'Something went wrong. Please try again.') } variant='destructive' > Error </Button> <Button onPress={() => warning('Warning!', 'Please review your input before continuing.') } variant='secondary' > Warning </Button> <Button onPress={() => info('Info', "Here's some helpful information for you.")} > Info </Button> </View> ); } ``` #### With Actions **Example:** Toast notifications with action buttons ```tsx // components/demo/toast/toast-actions.tsx import { Button } from '@/components/ui/button'; import { useToast } from '@/components/ui/toast'; import { View } from '@/components/ui/view'; import React from 'react'; export function ToastActions() { const { toast } = useToast(); const showToastWithAction = () => { toast({ title: 'New message received', description: 'You have a new message from John Doe.', variant: 'info', action: { label: 'View', onPress: () => { console.log('View action pressed'); // Navigate to message or perform action }, }, }); }; const showUndoToast = () => { toast({ title: 'Item deleted', description: 'The item has been removed from your list.', variant: 'warning', duration: 8000, // Longer duration for undo action action: { label: 'Undo', onPress: () => { console.log('Undo action pressed'); // Restore the deleted item }, }, }); }; return ( <View style={{ gap: 12 }}> <Button onPress={showToastWithAction} variant='outline'> Show with Action </Button> <Button onPress={showUndoToast} variant='outline'> Show Undo Toast </Button> </View> ); } ``` #### Custom Duration **Example:** Toast notifications with custom durations ```tsx // components/demo/toast/toast-duration.tsx import { Button } from '@/components/ui/button'; import { useToast } from '@/components/ui/toast'; import { View } from '@/components/ui/view'; import React from 'react'; export function ToastDuration() { const { toast } = useToast(); return ( <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 12 }}> <Button onPress={() => toast({ title: 'Quick toast', description: 'This disappears in 2 seconds', duration: 2000, variant: 'info', }) } variant='outline' > 2 seconds </Button> <Button onPress={() => toast({ title: 'Standard toast', description: 'This disappears in 4 seconds', duration: 4000, variant: 'default', }) } variant='outline' > 4 seconds (default) </Button> <Button onPress={() => toast({ title: 'Long toast', description: 'This disappears in 8 seconds', duration: 8000, variant: 'warning', }) } variant='outline' > 8 seconds </Button> <Button onPress={() => toast({ title: 'Persistent toast', description: "This won't disappear automatically", duration: 0, // No auto-dismiss variant: 'error', }) } variant='outline' > Persistent </Button> </View> ); } ``` #### Multiple Toasts **Example:** Multiple toast notifications stacked vertically ```tsx // components/demo/toast/toast-multiple.tsx import { Button } from '@/components/ui/button'; import { useToast } from '@/components/ui/toast'; import { View } from '@/components/ui/view'; import React from 'react'; export function ToastMultiple() { const { toast, dismissAll } = useToast(); const showMultipleToasts = () => { const variants = ['success', 'warning', 'error', 'info'] as const; const messages = [ { title: 'Success!', description: 'Operation completed successfully' }, { title: 'Warning', description: 'Please check your input' }, { title: 'Error', description: 'Something went wrong' }, { title: 'Info', description: "Here's some information" }, ]; variants.forEach((variant, index) => { setTimeout(() => { toast({ ...messages[index], variant, duration: 6000, }); }, index * 500); // Stagger the toasts }); }; const showBatchToasts = () => { // Show multiple toasts at once toast({ title: 'First toast', description: 'This is the first toast', variant: 'success', }); toast({ title: 'Second toast', description: 'This is the second toast', variant: 'info', }); toast({ title: 'Third toast', description: 'This is the third toast', variant: 'warning', }); }; return ( <View style={{ gap: 12 }}> <Button onPress={showMultipleToasts} variant='outline'> Show Staggered Toasts </Button> <Button onPress={showBatchToasts} variant='outline'> Show Batch Toasts </Button> <Button onPress={dismissAll} variant='destructive'> Dismiss All Toasts </Button> </View> ); } ``` #### Compact Mode **Example:** Compact toast notifications without title or description ```tsx // components/demo/toast/toast-compact.tsx import { Button } from '@/components/ui/button'; import { useToast } from '@/components/ui/toast'; import { View } from '@/components/ui/view'; import React from 'react'; export function ToastCompact() { const { toast } = useToast(); return ( <View style={{ gap: 12 }}> <Button onPress={() => toast({ variant: 'success', }) } variant='success' > Success Icon Only </Button> <Button onPress={() => toast({ variant: 'error', }) } variant='destructive' > Error Icon Only </Button> <Button onPress={() => toast({ variant: 'warning', }) } variant='secondary' > Warning Icon Only </Button> <Button onPress={() => toast({ variant: 'info', }) } variant='outline' > Info Icon Only </Button> <Button onPress={() => toast({ title: 'Title only', }) } > Title Only </Button> </View> ); } ``` ## API Reference ### ToastProvider The provider component that manages toast state and renders toasts. | Prop | Type | Default | Description | | ----------- | ----------- | ------- | -------------------------------------------- | | `children` | `ReactNode` | - | The app content to wrap with toast provider. | | `maxToasts` | `number` | `3` | Maximum number of toasts to display at once. | ### useToast Hook that provides methods to show and manage toasts. ```tsx const { toast, success, error, warning, info, dismiss, dismissAll } = useToast(); ``` #### Methods | Method | Type | Description | | ------------ | ----------------------------------------------- | ------------------------------- | | `toast` | `(data: ToastData) => void` | Show a toast with custom data. | | `success` | `(title: string, description?: string) => void` | Show a success toast. | | `error` | `(title: string, description?: string) => void` | Show an error toast. | | `warning` | `(title: string, description?: string) => void` | Show a warning toast. | | `info` | `(title: string, description?: string) => void` | Show an info toast. | | `dismiss` | `(id: string) => void` | Dismiss a specific toast by ID. | | `dismissAll` | `() => void` | Dismiss all active toasts. | ### ToastData Configuration object for toast notifications. | Prop | Type | Default | Description | | ------------- | ---------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------- | | `title` | `string` | - | The title of the toast. | | `description` | `string` | - | The description text of the toast. | | `variant` | `'default' \| 'success' \| 'error' \| 'warning' \| 'info'` | `'default'` | The visual variant of the toast. | | `duration` | `number` | `4000` | Duration in milliseconds before auto-dismiss. Set to 0 to disable auto-dismiss. | | `action` | `{ label: string; onPress: () => void }` | - | Optional action button configuration. | ## Animation The toast component features iOS Dynamic Island-inspired animations: - **Entrance**: Smooth slide-in from top with scale and opacity transitions - **Expansion**: Automatic expansion when content is present - **Gestures**: Swipe-to-dismiss with spring animations - **Stacking**: Multiple toasts stack vertically with proper spacing - **Exit**: Fade out with scale transition ## Accessibility The Toast component is built with accessibility in mind: - Each toast exposes `accessibilityRole="alert"` and `accessibilityLiveRegion="polite"` so screen readers announce it as it appears - The entry/exit spring animation and swipe-to-dismiss gesture are gated behind `AccessibilityInfo.isReduceMotionEnabled()` - Dismissible with both a swipe gesture and an explicit close button ## Customization ### Custom Colors You can customize the colors by modifying the `getVariantColor()` function in the component: ```tsx const getVariantColor = () => { switch (variant) { case 'success': return '#34D399'; // Custom green case 'error': return '#F87171'; // Custom red // ... other variants } }; ``` ### Custom Positioning Modify the `getTopPosition()` function to change toast positioning: ```tsx const getTopPosition = () => { const statusBarHeight = Platform.OS === 'ios' ? 59 : 20; const customOffset = 20; // Add custom offset return ( statusBarHeight + customOffset + index * (EXPANDED_HEIGHT + TOAST_MARGIN) ); }; ``` ### Custom Animations The component is built on `react-native-reanimated`'s `withSpring`. You can customize the spring config by editing the `SPRING_CONFIG` constant in `toast.tsx`: ```tsx const SPRING_CONFIG = { stiffness: 120, // Custom stiffness damping: 8, // Custom damping }; translateY.value = withSpring(0, SPRING_CONFIG); ``` <!-- ---------------------------------------------------------------------- --> # Toggle > A two-state button that can be either on or off. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/toggle - Markdown: https://ui.ahmedbna.com/docs/components/toggle.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/toggle.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/toggle.json - Install: `npx bna-ui add toggle` - npm dependencies: `expo-haptics`, `lucide-react-native`, `react-native-svg` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `useHaptics`, `globals`, `text`, `view`, `icon` - Preview recording: https://demo.ahmedbna.com/0312-toggle-demo.MP4 --- **Example:** A basic toggle button with icon ```tsx // components/demo/toggle/toggle-demo.tsx import { Toggle } from '@/components/ui/toggle'; import { Bold } from 'lucide-react-native'; import React, { useState } from 'react'; export function ToggleDemo() { const [pressed, setPressed] = useState(false); return ( <Toggle pressed={pressed} onPressedChange={setPressed}> <Bold size={16} /> </Toggle> ); } ``` ## Installation ### CLI ```bash npx bna-ui add toggle ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install lucide-react-native ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/toggle.tsx import { Icon } from '@/components/ui/icon'; import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { useHaptics } from '@/hooks/useHaptics'; import { CORNERS, FONT_SIZE, HEIGHT } from '@/theme/globals'; import { LucideProps } from 'lucide-react-native'; import React, { useCallback } from 'react'; import { TextStyle, TouchableOpacity, ViewStyle } from 'react-native'; type ToggleVariant = 'default' | 'outline'; type ToggleSize = 'default' | 'icon'; interface ToggleProps { children: React.ReactNode; pressed?: boolean; onPressedChange?: (pressed: boolean) => void; variant?: ToggleVariant; size?: ToggleSize; disabled?: boolean; style?: ViewStyle; textStyle?: TextStyle; haptic?: boolean; } export function Toggle({ children, pressed = false, onPressedChange, variant = 'default', size = 'icon', disabled = false, style, textStyle, haptic = true, }: ToggleProps) { const primaryColor = useColor('primary'); const primaryForegroundColor = useColor('primaryForeground'); const secondaryColor = useColor('secondary'); const secondaryForegroundColor = useColor('secondaryForeground'); const borderColor = useColor('border'); const feedback = useHaptics(haptic); // The single source of haptic feedback for toggles: ToggleGroup and // ToggleGroupItemButton forward `haptic` down rather than firing their own, // so a grouped item still buzzes exactly once. const handlePress = () => { if (!disabled) { feedback(pressed ? 'toggle-off' : 'toggle-on'); onPressedChange?.(!pressed); } }; const getToggleStyle = (): ViewStyle => { const baseStyle: ViewStyle = { borderRadius: CORNERS, alignItems: 'center', justifyContent: 'center', flexDirection: 'row', }; // Size variants - following button component pattern switch (size) { case 'icon': Object.assign(baseStyle, { width: HEIGHT, height: HEIGHT, }); break; default: Object.assign(baseStyle, { height: HEIGHT, paddingHorizontal: 32 }); } // State and variant styles - following button component pattern if (pressed) { switch (variant) { case 'outline': return { ...baseStyle, backgroundColor: primaryColor, borderWidth: 1, borderColor: primaryColor, }; default: return { ...baseStyle, backgroundColor: primaryColor, }; } } else { switch (variant) { case 'outline': return { ...baseStyle, backgroundColor: 'transparent', borderWidth: 1, borderColor: borderColor, }; default: return { ...baseStyle, backgroundColor: secondaryColor, }; } } }; const getToggleTextStyle = (): TextStyle => { const baseTextStyle: TextStyle = { fontSize: FONT_SIZE, fontWeight: '500', }; if (pressed) { switch (variant) { case 'outline': return { ...baseTextStyle, color: primaryForegroundColor }; default: return { ...baseTextStyle, color: primaryForegroundColor }; } } else { switch (variant) { case 'outline': return { ...baseTextStyle, color: primaryColor }; default: return { ...baseTextStyle, color: secondaryForegroundColor }; } } }; const toggleStyle = getToggleStyle(); const finalTextStyle = getToggleTextStyle(); return ( <TouchableOpacity style={[toggleStyle, disabled && { opacity: 0.5 }, style]} onPress={handlePress} disabled={disabled} activeOpacity={0.8} accessibilityRole='togglebutton' accessibilityState={{ selected: pressed, disabled }} > {typeof children === 'string' ? ( <Text style={[finalTextStyle, textStyle]}>{children}</Text> ) : ( children )} </TouchableOpacity> ); } type ToggleGroupType = 'single' | 'multiple'; type ToggleGroupVariant = 'default' | 'outline'; type ToggleGroupSize = 'default' | 'icon'; interface ToggleGroupItem { value: string; label: string; icon?: React.ComponentType<LucideProps>; disabled?: boolean; } interface ToggleGroupProps { type?: ToggleGroupType; value?: string | string[]; onValueChange?: (value: string | string[]) => void; items: ToggleGroupItem[]; variant?: ToggleGroupVariant; size?: ToggleGroupSize; disabled?: boolean; style?: ViewStyle; orientation?: 'horizontal' | 'vertical'; haptic?: boolean; } // Split out and memoized so a selection change only re-renders the affected // item, not every item in the group — pointless without a stable onPress // identity, which is why ToggleGroup wraps handleItemPress in useCallback. const ToggleGroupItemButton = React.memo(function ToggleGroupItemButton({ item, pressed, variant, size, disabled, style, onPress, haptic, }: { item: ToggleGroupItem; pressed: boolean; variant: ToggleGroupVariant; size: ToggleGroupSize; disabled: boolean; style: ViewStyle; onPress: (value: string) => void; haptic: boolean; }) { const primaryColor = useColor('primary'); const primaryForegroundColor = useColor('primaryForeground'); const secondaryForegroundColor = useColor('secondaryForeground'); const handlePress = useCallback(() => { onPress(item.value); }, [onPress, item.value]); const color = pressed ? primaryForegroundColor : variant === 'outline' ? primaryColor : secondaryForegroundColor; return ( <Toggle pressed={pressed} onPressedChange={handlePress} variant={variant} size={size} disabled={disabled} style={style} haptic={haptic} > {item.icon && item.label ? ( <View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}> <Icon name={item.icon} size={16} strokeWidth={2.5} color={color} /> <Text style={{ color }}>{item.label}</Text> </View> ) : item.icon ? ( <Icon name={item.icon} size={16} strokeWidth={2.5} color={color} /> ) : ( <Text style={{ color }}>{item.label}</Text> )} </Toggle> ); }); export function ToggleGroup({ type = 'single', value, onValueChange, items, variant = 'default', size = 'default', disabled = false, style, orientation = 'horizontal', haptic = true, }: ToggleGroupProps) { const borderColor = useColor('border'); const handleItemPress = useCallback( (itemValue: string) => { if (disabled) return; if (type === 'single') { // Single selection const newValue = value === itemValue ? undefined : itemValue; onValueChange?.(newValue || ''); } else { // Multiple selection const currentValues = Array.isArray(value) ? value : []; const newValues = currentValues.includes(itemValue) ? currentValues.filter((v) => v !== itemValue) : [...currentValues, itemValue]; onValueChange?.(newValues); } }, [disabled, type, value, onValueChange] ); const isItemPressed = (itemValue: string): boolean => { if (type === 'single') { return value === itemValue; } else { return Array.isArray(value) && value.includes(itemValue); } }; const containerStyle: ViewStyle = { flexDirection: orientation === 'horizontal' ? 'row' : 'column', borderWidth: 1, borderColor: borderColor, borderRadius: CORNERS, overflow: 'hidden', backgroundColor: 'transparent', }; const getItemStyle = (index: number): ViewStyle => { const isLast = index === items.length - 1; const itemStyle: ViewStyle = { flex: orientation === 'horizontal' ? 1 : 0, borderRadius: 0, borderWidth: 0, borderRightWidth: orientation === 'horizontal' && !isLast ? 1 : 0, borderBottomWidth: orientation === 'vertical' && !isLast ? 1 : 0, borderColor: borderColor, }; return itemStyle; }; return ( <View style={[containerStyle, style]} accessibilityRole={type === 'single' ? 'radiogroup' : undefined} > {items.map((item, index) => ( <ToggleGroupItemButton key={item.value} item={item} pressed={isItemPressed(item.value)} variant={variant} size={size} disabled={disabled || !!item.disabled} style={getItemStyle(index)} onPress={handleItemPress} haptic={haptic} /> ))} </View> ); } // Convenience components for common use cases export function ToggleGroupSingle({ value, onValueChange, ...props }: Omit<ToggleGroupProps, 'type' | 'value' | 'onValueChange'> & { value?: string; onValueChange?: (value: string) => void; }) { const handleValueChange = (newValue: string | string[]) => { // For single selection, we know it will always be a string onValueChange?.(newValue as string); }; return ( <ToggleGroup type='single' value={value} onValueChange={handleValueChange} {...props} /> ); } export function ToggleGroupMultiple({ value, onValueChange, ...props }: Omit<ToggleGroupProps, 'type' | 'value' | 'onValueChange'> & { value?: string[]; onValueChange?: (value: string[]) => void; }) { const handleValueChange = (newValue: string | string[]) => { // For multiple selection, we know it will always be a string array onValueChange?.(newValue as string[]); }; return ( <ToggleGroup type='multiple' value={value} onValueChange={handleValueChange} {...props} /> ); } ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { Toggle, ToggleGroup, ToggleGroupSingle, ToggleGroupMultiple, } from '@/components/ui/toggle'; ``` ```tsx <Toggle pressed={pressed} onPressedChange={setPressed}> <Bold size={16} /> </Toggle> ``` ## Examples #### Default **Example:** A basic toggle button with icon ```tsx // components/demo/toggle/toggle-demo.tsx import { Toggle } from '@/components/ui/toggle'; import { Bold } from 'lucide-react-native'; import React, { useState } from 'react'; export function ToggleDemo() { const [pressed, setPressed] = useState(false); return ( <Toggle pressed={pressed} onPressedChange={setPressed}> <Bold size={16} /> </Toggle> ); } ``` #### Variants **Example:** Toggle buttons in different variants ```tsx // components/demo/toggle/toggle-variants.tsx import { Toggle } from '@/components/ui/toggle'; import { View } from '@/components/ui/view'; import { Bold, Italic } from 'lucide-react-native'; import React, { useState } from 'react'; export function ToggleVariants() { const [pressed1, setPressed1] = useState(false); const [pressed2, setPressed2] = useState(true); const [pressed3, setPressed3] = useState(false); const [pressed4, setPressed4] = useState(true); return ( <View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}> <Toggle pressed={pressed1} onPressedChange={setPressed1} variant='default' > <Bold size={16} /> </Toggle> <Toggle pressed={pressed2} onPressedChange={setPressed2} variant='default' > <Italic size={16} /> </Toggle> <Toggle pressed={pressed3} onPressedChange={setPressed3} variant='outline' > <Bold size={16} /> </Toggle> <Toggle pressed={pressed4} onPressedChange={setPressed4} variant='outline' > <Italic size={16} /> </Toggle> </View> ); } ``` #### Sizes **Example:** Toggle buttons in different sizes ```tsx // components/demo/toggle/toggle-sizes.tsx import { Toggle } from '@/components/ui/toggle'; import { View } from '@/components/ui/view'; import { Bold } from 'lucide-react-native'; import React, { useState } from 'react'; export function ToggleSizes() { const [pressed1, setPressed1] = useState(false); const [pressed2, setPressed2] = useState(true); return ( <View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}> <Toggle pressed={pressed1} onPressedChange={setPressed1} size='icon'> <Bold size={16} /> </Toggle> <Toggle pressed={pressed2} onPressedChange={setPressed2} size='default'> Bold </Toggle> </View> ); } ``` #### With Text **Example:** Toggle buttons with text labels ```tsx // components/demo/toggle/toggle-text.tsx import { Toggle } from '@/components/ui/toggle'; import { View } from '@/components/ui/view'; import React, { useState } from 'react'; export function ToggleText() { const [pressed1, setPressed1] = useState(false); const [pressed2, setPressed2] = useState(true); const [pressed3, setPressed3] = useState(false); return ( <View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}> <Toggle pressed={pressed1} onPressedChange={setPressed1} size='default'> Bold </Toggle> <Toggle pressed={pressed2} onPressedChange={setPressed2} size='default' variant='outline' > Italic </Toggle> <Toggle pressed={pressed3} onPressedChange={setPressed3} size='default'> Underline </Toggle> </View> ); } ``` #### Disabled **Example:** Disabled toggle buttons ```tsx // components/demo/toggle/toggle-disabled.tsx import { Toggle } from '@/components/ui/toggle'; import { View } from '@/components/ui/view'; import { Bold, Italic } from 'lucide-react-native'; import React from 'react'; export function ToggleDisabled() { return ( <View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}> <Toggle pressed={false} disabled> <Bold size={16} /> </Toggle> <Toggle pressed={true} disabled> <Italic size={16} /> </Toggle> <Toggle pressed={false} disabled variant='outline'> <Bold size={16} /> </Toggle> <Toggle pressed={true} disabled variant='outline'> <Italic size={16} /> </Toggle> </View> ); } ``` #### Toggle Group Single **Example:** Single selection toggle group ```tsx // components/demo/toggle/toggle-group-single.tsx import { ToggleGroupSingle } from '@/components/ui/toggle'; import { AlignCenter, AlignLeft, AlignRight } from 'lucide-react-native'; import React, { useState } from 'react'; export function ToggleGroupSingleDemo() { const [value, setValue] = useState('left'); const items = [ { value: 'left', label: 'Left', icon: AlignLeft }, { value: 'center', label: 'Center', icon: AlignCenter }, { value: 'right', label: 'Right', icon: AlignRight }, ]; return ( <ToggleGroupSingle value={value} onValueChange={setValue} items={items} size='icon' /> ); } ``` #### Toggle Group Multiple **Example:** Multiple selection toggle group ```tsx // components/demo/toggle/toggle-group-multiple.tsx import { ToggleGroupMultiple } from '@/components/ui/toggle'; import { Bold, Italic, Underline } from 'lucide-react-native'; import React, { useState } from 'react'; export function ToggleGroupMultipleDemo() { const [value, setValue] = useState(['bold']); const items = [ { value: 'bold', label: 'Bold', icon: Bold }, { value: 'italic', label: 'Italic', icon: Italic }, { value: 'underline', label: 'Underline', icon: Underline }, ]; return ( <ToggleGroupMultiple value={value} onValueChange={setValue} items={items} size='icon' /> ); } ``` #### Toggle Group Vertical **Example:** Vertical toggle group layout ```tsx // components/demo/toggle/toggle-group-vertical.tsx import { ToggleGroupSingle } from '@/components/ui/toggle'; import { AlignCenter, AlignLeft, AlignRight } from 'lucide-react-native'; import React, { useState } from 'react'; export function ToggleGroupVertical() { const [value, setValue] = useState('left'); const items = [ { value: 'left', label: 'Left Align', icon: AlignLeft }, { value: 'center', label: 'Center Align', icon: AlignCenter }, { value: 'right', label: 'Right Align', icon: AlignRight }, ]; return ( <ToggleGroupSingle value={value} onValueChange={setValue} items={items} orientation='vertical' size='default' /> ); } ``` #### Toggle Group Outline **Example:** Toggle group with outline variant ```tsx // components/demo/toggle/toggle-group-outline.tsx import { ToggleGroupSingle } from '@/components/ui/toggle'; import { Bold, Italic, Underline } from 'lucide-react-native'; import React, { useState } from 'react'; export function ToggleGroupOutline() { const [value, setValue] = useState('bold'); const items = [ { value: 'bold', label: 'Bold', icon: Bold }, { value: 'italic', label: 'Italic', icon: Italic }, { value: 'underline', label: 'Underline', icon: Underline }, ]; return ( <ToggleGroupSingle value={value} onValueChange={setValue} items={items} variant='outline' size='default' /> ); } ``` ## API Reference ### Toggle A two-state button that can be either on (pressed) or off. Uses `pressed`/`onPressedChange` (ARIA `aria-pressed` semantics) rather than `checkbox`'s `checked`/`onCheckedChange` or `radio`'s `value`/`onValueChange` — each naming convention matches its own control's interaction model and is intentional, not an inconsistency. `ToggleGroup` below uses `value`/`onValueChange` instead, matching `RadioGroup`'s group-selection convention. | Prop | Type | Default | Description | | ----------------- | ---------------------------- | ----------- | -------------------------------------------------------------- | | `haptic` | `boolean` | `true` | Whether to trigger haptic feedback when the toggle is pressed. | | `children` | `ReactNode` | - | The content to display inside the toggle. | | `pressed` | `boolean` | `false` | Whether the toggle is pressed (on). | | `onPressedChange` | `(pressed: boolean) => void` | - | Callback fired when the pressed state changes. | | `variant` | `'default' \| 'outline'` | `'default'` | The visual variant of the toggle. | | `size` | `'default' \| 'icon'` | `'icon'` | The size of the toggle. | | `disabled` | `boolean` | `false` | Whether the toggle is disabled. | | `style` | `ViewStyle` | - | Additional styles to apply to the toggle container. | | `textStyle` | `TextStyle` | - | Additional styles to apply to the toggle text. | ### ToggleGroup A set of two-state buttons that can be toggled on or off. | Prop | Type | Default | Description | | --------------- | ------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------- | | `haptic` | `boolean` | `true` | Whether to trigger haptic feedback when an item is pressed. Forwarded to each item so a press is felt exactly once. | | `type` | `'single' \| 'multiple'` | `'single'` | Whether to allow single or multiple selection. | | `value` | `string \| string[]` | - | The controlled value(s) of the toggle group. | | `onValueChange` | `(value: string \| string[]) => void` | - | Callback fired when the value changes. | | `items` | `ToggleGroupItem[]` | - | Array of toggle items to render. | | `variant` | `'default' \| 'outline'` | `'default'` | The visual variant of the toggles. | | `size` | `'default' \| 'icon'` | `'default'` | The size of the toggles. | | `disabled` | `boolean` | `false` | Whether the entire group is disabled. | | `style` | `ViewStyle` | - | Additional styles to apply to the group container. | | `orientation` | `'horizontal' \| 'vertical'` | `'horizontal'` | The orientation of the toggle group. | ### ToggleGroupItem Configuration for individual items in a toggle group. | Prop | Type | Default | Description | | ---------- | ---------------------------------- | ------- | --------------------------------------- | | `value` | `string` | - | The unique value for this toggle item. | | `label` | `string` | - | The text label for this toggle item. | | `icon` | `React.ComponentType<LucideProps>` | - | Optional icon component to display. | | `disabled` | `boolean` | `false` | Whether this specific item is disabled. | ### ToggleGroupSingle Convenience component for single-selection toggle groups. | Prop | Type | Description | | --------------- | ----------------------------- | ------------------------------------------------------ | | `value` | `string` | The controlled value of the selected toggle. | | `onValueChange` | `(value: string) => void` | Callback fired when the selected value changes. | | `...props` | `Omit<ToggleGroupProps, ...>` | All other ToggleGroup props except type and callbacks. | ### ToggleGroupMultiple Convenience component for multiple-selection toggle groups. | Prop | Type | Description | | --------------- | ----------------------------- | ------------------------------------------------------ | | `value` | `string[]` | The controlled array of selected toggle values. | | `onValueChange` | `(value: string[]) => void` | Callback fired when the selected values change. | | `...props` | `Omit<ToggleGroupProps, ...>` | All other ToggleGroup props except type and callbacks. | ## Accessibility The Toggle components are built with accessibility in mind: - Uses TouchableOpacity for proper touch handling - Supports disabled state with visual feedback - Proper color contrast for different states - Screen reader compatible with semantic structure - Keyboard navigation support through native React Native components <!-- ---------------------------------------------------------------------- --> # Video > A video player component with custom controls, gestures, and subtitle support. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/video - Markdown: https://ui.ahmedbna.com/docs/components/video.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/video.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/video.json - Install: `npx bna-ui add video` - npm dependencies: `expo-video`, `lucide-react-native`, `react-native-gesture-handler`, `react-native-reanimated`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `view`, `progress`, `text` - Preview recording: https://demo.ahmedbna.com/0321-video-demo.MP4 --- **Example:** A basic video player with custom controls ```tsx // components/demo/video/video-demo.tsx import { Video } from '@/components/ui/video'; export function VideoDemo() { return ( <Video source={{ uri: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4', }} style={{ width: '100%', height: 200, borderRadius: 8, }} autoPlay={false} loop={false} muted={false} showControls={true} /> ); } ``` ## Installation ### CLI ```bash npx bna-ui add video ``` ### Manual **1.** Install the following dependencies: ```bash npx expo install expo-video lucide-react-native ``` **2.** Copy and paste the following code into your project. ```tsx // components/ui/video.tsx import { useColor } from '@/hooks/useColor'; import { BORDER_RADIUS } from '@/theme/globals'; import { useEvent } from 'expo'; import { useVideoPlayer, VideoSource, VideoView } from 'expo-video'; import { Pause, Play, Volume2, VolumeX } from 'lucide-react-native'; import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState, } from 'react'; import { StyleSheet, Text, TouchableOpacity, View, ViewStyle, } from 'react-native'; import { Gesture, GestureDetector, GestureHandlerRootView, } from 'react-native-gesture-handler'; import Animated, { runOnJS, useAnimatedStyle, useDerivedValue, useSharedValue, withTiming, } from 'react-native-reanimated'; interface VideoProps { source: VideoSource; style?: ViewStyle; seekBy?: number; // seconds to seek by on double tap autoPlay?: boolean; loop?: boolean; muted?: boolean; nativeControls?: boolean; showControls?: boolean; /** * Kept as a boolean on this component's own API — SDK 55 replaced * `VideoView`'s `allowsFullscreen` prop with `fullscreenOptions.enable`, * and that translation happens internally so consumers don't have to care. */ allowsFullscreen?: boolean; allowsPictureInPicture?: boolean; contentFit?: 'contain' | 'cover' | 'fill'; onLoad?: () => void; onError?: (error: any) => void; onPlaybackStatusUpdate?: (status: any) => void; onFullscreenUpdate?: (isFullscreen: boolean) => void; subtitles?: Array<{ start: number; end: number; text: string; }>; } interface VideoRef { play: () => void; pause: () => void; seekTo: (seconds: number) => void; setVolume: (volume: number) => void; getCurrentTime: () => number; getDuration: () => number; isPlaying: () => boolean; isMuted: () => boolean; } // Helper function to format time const formatTime = (seconds: number): string => { if (isNaN(seconds) || seconds < 0) return '0:00'; const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, '0')}`; }; // --- Custom Reanimated Progress Bar --- const PROGRESS_HEIGHT = 8; const THUMB_SIZE = 16; interface ReanimatedProgressProps { duration: number; currentTime: number; onSeek: (progress: number) => void; onSeekStart?: () => void; onSeekEnd?: () => void; } const ReanimatedProgress = ({ duration, currentTime, onSeek, onSeekStart, onSeekEnd, }: ReanimatedProgressProps) => { const [barWidth, setBarWidth] = useState(0); const isScrubbing = useSharedValue(false); const translateX = useSharedValue(0); const scale = useSharedValue(1); useDerivedValue(() => { if (!isScrubbing.value && duration > 0 && barWidth > 0) { const progress = currentTime / duration; translateX.value = withTiming(progress * barWidth, { duration: 100 }); } }); const panGesture = Gesture.Pan() .minDistance(1) .onBegin(() => { isScrubbing.value = true; scale.value = withTiming(1.2); if (onSeekStart) runOnJS(onSeekStart)(); }) .onChange((event) => { translateX.value = Math.max( 0, Math.min(barWidth, translateX.value + event.changeX) ); }) .onEnd(() => { const finalProgress = translateX.value / barWidth; runOnJS(onSeek)(finalProgress); isScrubbing.value = false; scale.value = withTiming(1); if (onSeekEnd) runOnJS(onSeekEnd)(); }); const tapGesture = Gesture.Tap() .onBegin(() => { if (onSeekStart) runOnJS(onSeekStart)(); }) .onEnd((event) => { const newTranslateX = Math.max(0, Math.min(barWidth, event.x)); translateX.value = newTranslateX; const finalProgress = newTranslateX / barWidth; runOnJS(onSeek)(finalProgress); if (onSeekEnd) runOnJS(onSeekEnd)(); }); const composedGesture = Gesture.Race(panGesture, tapGesture); const animatedProgressStyle = useAnimatedStyle(() => ({ width: translateX.value, })); const animatedThumbStyle = useAnimatedStyle(() => ({ transform: [{ translateX: translateX.value }, { scale: scale.value }], })); return ( <GestureDetector gesture={composedGesture}> <Animated.View style={progressStyles.container} onLayout={(e) => setBarWidth(e.nativeEvent.layout.width)} > <View style={progressStyles.track} /> <Animated.View style={[progressStyles.progress, animatedProgressStyle]} /> <Animated.View style={[progressStyles.thumbContainer, animatedThumbStyle]} > <View style={progressStyles.thumb} /> </Animated.View> </Animated.View> </GestureDetector> ); }; // --- Main Video Component --- export const Video = forwardRef<VideoRef, VideoProps>( ( { source, style, autoPlay = false, loop = false, muted = false, nativeControls = false, allowsFullscreen = true, allowsPictureInPicture = true, contentFit = 'cover', onLoad, onError, seekBy = 2, onPlaybackStatusUpdate, onFullscreenUpdate, subtitles = [], ...props }, ref ) => { const textColor = useColor('text'); const cardColor = useColor('card'); const mutedColor = useColor('mutedForeground'); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); const [isMuted, setIsMuted] = useState(muted); const [currentSubtitle, setCurrentSubtitle] = useState<string>(''); const [isVideoEnded, setIsVideoEnded] = useState(false); const [showPlayIcon, setShowPlayIcon] = useState(false); const [showCustomControls, setShowCustomControls] = useState(false); const [isSeeking, setIsSeeking] = useState(false); const nativeRef = useRef<VideoView>(null); const hideControlsTimeout = useRef<ReturnType<typeof setTimeout> | null>( null ); const hidePlayIconTimeout = useRef<ReturnType<typeof setTimeout> | null>( null ); const controlsOpacity = useSharedValue(0); const playIconOpacity = useSharedValue(0); const player = useVideoPlayer(source, (player) => { try { if (autoPlay && player.play) player.play(); player.loop = loop; player.muted = muted; onLoad?.(); } catch (error) { console.error('Video player initialization error:', error); onError?.(error); } }); const { isPlaying } = useEvent(player, 'playingChange', { isPlaying: player?.playing || false, }); useImperativeHandle(ref, () => ({ play: () => player.play(), pause: () => player.pause(), seekTo: (seconds: number) => { player.currentTime = seconds; }, setVolume: (volume: number) => { player.volume = volume; }, getCurrentTime: () => player.currentTime, getDuration: () => player.duration, isPlaying: () => player.playing, isMuted: () => player.muted, })); // --- !! EFFECT UPDATED TO RESPECT isSeeking STATE !! --- useEffect(() => { const interval = setInterval(() => { // Only update time from player if the user is not actively seeking if (player && !isSeeking) { const time = player.currentTime || 0; const dur = player.duration || 0; setCurrentTime(time); if (dur > 0) setDuration(dur); if (dur > 0 && time >= dur - 0.25 && !loop) setIsVideoEnded(true); else setIsVideoEnded(false); const activeSubtitle = subtitles.find( (s) => time >= s.start && time <= s.end ); setCurrentSubtitle(activeSubtitle?.text || ''); onPlaybackStatusUpdate?.({ currentTime: time, duration: dur, isPlaying: player.playing, }); } }, 250); return () => clearInterval(interval); }, [player, subtitles, onPlaybackStatusUpdate, loop, isSeeking]); const controlsAnimatedStyle = useAnimatedStyle(() => ({ opacity: controlsOpacity.value, })); const playIconAnimatedStyle = useAnimatedStyle(() => ({ opacity: playIconOpacity.value, })); const showControls = useCallback(() => { setShowCustomControls(true); controlsOpacity.value = withTiming(1, { duration: 200 }); if (hideControlsTimeout.current) clearTimeout(hideControlsTimeout.current); if (isPlaying) { // Only hide controls if video is playing hideControlsTimeout.current = setTimeout(hideControls, 3000); } }, [controlsOpacity, isPlaying]); const hideControls = useCallback(() => { controlsOpacity.value = withTiming(0, { duration: 200 }, (isFinished) => { if (isFinished) runOnJS(setShowCustomControls)(false); }); }, [controlsOpacity]); const showPlayIconAnimation = useCallback(() => { setShowPlayIcon(true); playIconOpacity.value = withTiming(1, { duration: 200 }); if (hidePlayIconTimeout.current) clearTimeout(hidePlayIconTimeout.current); hidePlayIconTimeout.current = setTimeout(() => { playIconOpacity.value = withTiming( 0, { duration: 200 }, (isFinished) => { if (isFinished) runOnJS(setShowPlayIcon)(false); } ); }, 1000); }, [playIconOpacity]); const handleSingleTap = useCallback(() => { if (!player) return; if (isVideoEnded) { player.currentTime = 0; player.play(); setIsVideoEnded(false); } else { player.playing ? player.pause() : player.play(); } showPlayIconAnimation(); showControls(); }, [player, isVideoEnded, showControls, showPlayIconAnimation]); const handleLeftDoubleTap = useCallback(() => { if (player) { player.seekBy(-seekBy); showControls(); } }, [player, showControls, seekBy]); const handleRightDoubleTap = useCallback(() => { if (player) { player.seekBy(seekBy); showControls(); } }, [player, showControls, seekBy]); const toggleMute = useCallback(() => { const newMuted = !isMuted; setIsMuted(newMuted); player.muted = newMuted; }, [isMuted, player]); const handleProgressChange = useCallback( (progress: number) => { if (!player || !duration || duration <= 0) return; const newTime = progress * duration; // This is the "optimistic update" - we set the local state immediately setCurrentTime(newTime); player.currentTime = newTime; if (isVideoEnded) setIsVideoEnded(false); // Reset the hide controls timer if (hideControlsTimeout.current) clearTimeout(hideControlsTimeout.current); hideControlsTimeout.current = setTimeout(hideControls, 3000); }, [player, duration, isVideoEnded, hideControls] ); const handleSeekStart = useCallback(() => { setIsSeeking(true); if (hideControlsTimeout.current) clearTimeout(hideControlsTimeout.current); }, []); const handleSeekEnd = useCallback(() => { setIsSeeking(false); }, []); useEffect(() => { return () => { if (hideControlsTimeout.current) clearTimeout(hideControlsTimeout.current); if (hidePlayIconTimeout.current) clearTimeout(hidePlayIconTimeout.current); }; }, []); return ( <GestureHandlerRootView style={[styles.container, { backgroundColor: cardColor }, style]} > <VideoView ref={nativeRef} player={player} style={styles.video} fullscreenOptions={{ enable: allowsFullscreen }} allowsPictureInPicture={allowsPictureInPicture} nativeControls={nativeControls} contentFit={contentFit} onFullscreenEnter={() => onFullscreenUpdate?.(true)} onFullscreenExit={() => onFullscreenUpdate?.(false)} {...props} /> <View style={styles.gestureOverlay}> <TouchableOpacity style={styles.gestureArea} onPress={handleLeftDoubleTap} activeOpacity={0} accessibilityRole='button' accessibilityLabel={`Rewind ${seekBy} seconds`} /> <TouchableOpacity style={styles.gestureAreaCenter} onPress={handleSingleTap} activeOpacity={0} accessibilityRole='button' accessibilityLabel={isPlaying ? 'Pause' : 'Play'} /> <TouchableOpacity style={styles.gestureArea} onPress={handleRightDoubleTap} activeOpacity={0} accessibilityRole='button' accessibilityLabel={`Forward ${seekBy} seconds`} /> </View> {showCustomControls && ( <Animated.View style={[styles.controlsContainer, controlsAnimatedStyle]} pointerEvents='box-none' > <View style={styles.topControls}> <TouchableOpacity onPress={toggleMute} style={styles.controlButton} activeOpacity={0.7} accessibilityRole='button' accessibilityLabel={isMuted ? 'Unmute' : 'Mute'} > {isMuted ? ( <VolumeX size={24} color={textColor} /> ) : ( <Volume2 size={24} color={textColor} /> )} </TouchableOpacity> </View> <View style={styles.bottomControls}> <View style={styles.timeContainer}> <Text style={[styles.timeText, { color: mutedColor }]}> {formatTime(currentTime)} </Text> <Text style={[styles.timeText, { color: mutedColor }]}> {formatTime(duration)} </Text> </View> <ReanimatedProgress duration={duration} currentTime={currentTime} onSeek={handleProgressChange} onSeekStart={handleSeekStart} onSeekEnd={handleSeekEnd} /> </View> </Animated.View> )} </GestureHandlerRootView> ); } ); Video.displayName = 'Video'; const styles = StyleSheet.create({ container: { width: '100%', height: '100%', borderRadius: BORDER_RADIUS, overflow: 'hidden', }, video: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, width: '100%', height: '100%', }, gestureOverlay: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, flexDirection: 'row', }, gestureArea: { flex: 1, backgroundColor: 'transparent' }, gestureAreaCenter: { flex: 2, backgroundColor: 'transparent' }, centerPlayIcon: { position: 'absolute', top: '50%', left: '50%', transform: [{ translateX: -40 }, { translateY: -40 }], zIndex: 100, }, centerPlayIconBackground: { width: 80, height: 80, borderRadius: 40, backgroundColor: 'rgba(0, 0, 0, 0.7)', justifyContent: 'center', alignItems: 'center', }, subtitleContainer: { position: 'absolute', bottom: 80, left: 20, right: 20, alignItems: 'center', }, subtitleText: { fontSize: 16, textAlign: 'center', backgroundColor: 'rgba(0, 0, 0, 0.7)', paddingHorizontal: 12, paddingVertical: 8, borderRadius: 6, }, controlsContainer: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0, 0, 0, 0.3)', justifyContent: 'space-between', }, topControls: { flexDirection: 'row', justifyContent: 'flex-end', padding: 16, }, bottomControls: { padding: 16, gap: 6, paddingBottom: 6 }, timeContainer: { flexDirection: 'row', justifyContent: 'space-between' }, timeText: { fontSize: 12 }, controlButton: { width: 44, height: 44, borderRadius: 22, justifyContent: 'center', alignItems: 'center', backgroundColor: 'rgba(0, 0, 0, 0.5)', }, }); const progressStyles = StyleSheet.create({ container: { height: THUMB_SIZE * 2, justifyContent: 'center' }, track: { height: PROGRESS_HEIGHT, backgroundColor: 'rgba(255, 255, 255, 0.3)', borderRadius: PROGRESS_HEIGHT / 2, }, progress: { height: PROGRESS_HEIGHT, backgroundColor: '#FFFFFF', borderRadius: PROGRESS_HEIGHT / 2, position: 'absolute', }, thumbContainer: { position: 'absolute', top: (THUMB_SIZE * 2 - THUMB_SIZE) / 2, left: -THUMB_SIZE / 2, }, thumb: { width: THUMB_SIZE, height: THUMB_SIZE, borderRadius: THUMB_SIZE / 2, backgroundColor: '#FFFFFF', }, }); export type { VideoProps, VideoRef, VideoSource }; ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { Video } from '@/components/ui/video'; ``` ```tsx <Video source={{ uri: 'https://example.com/video.mp4' }} autoPlay={true} showControls={true} /> ``` ## Examples #### Default **Example:** A basic video player with custom controls ```tsx // components/demo/video/video-demo.tsx import { Video } from '@/components/ui/video'; export function VideoDemo() { return ( <Video source={{ uri: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4', }} style={{ width: '100%', height: 200, borderRadius: 8, }} autoPlay={false} loop={false} muted={false} showControls={true} /> ); } ``` #### Native Controls **Example:** Video player using native system controls ```tsx // components/demo/video/video-native-controls.tsx import { Video } from '@/components/ui/video'; import React from 'react'; export function VideoNativeControls() { return ( <Video source={{ uri: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4', }} style={{ width: '100%', height: 200, borderRadius: 8, }} nativeControls={true} autoPlay={false} loop={false} /> ); } ``` #### Custom Controls **Example:** Video player with custom control interface ```tsx // components/demo/video/video-custom-controls.tsx import { Video } from '@/components/ui/video'; import React from 'react'; export function VideoCustomControls() { return ( <Video source={{ uri: 'https://ui.ahmedbna.com/', }} style={{ width: '100%', height: 250, borderRadius: 12, }} nativeControls={false} showControls={true} autoPlay={false} loop={true} seekBy={5} onPlaybackStatusUpdate={(status) => { console.log('Playback status:', status); }} onLoad={() => { console.log('Video loaded successfully'); }} /> ); } ``` #### With Subtitles **Example:** Video player with subtitle support ```tsx // components/demo/video/video-subtitles.tsx import { Video } from '@/components/ui/video'; import React from 'react'; export function VideoSubtitles() { const subtitles = [ { start: 0, end: 3, text: 'Welcome to our video demo' }, { start: 3, end: 6, text: 'This video shows subtitle support' }, { start: 6, end: 9, text: 'Subtitles appear at the bottom' }, { start: 9, end: 12, text: 'They automatically sync with playback' }, { start: 12, end: 15, text: 'Perfect for accessibility!' }, ]; return ( <Video source={{ uri: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/SubaruOutbackOnStreetAndDirt.mp4', }} style={{ width: '100%', height: 200, borderRadius: 8, }} subtitles={subtitles} autoPlay={true} loop={true} showControls={true} /> ); } ``` #### Autoplay & Loop **Example:** Video that automatically plays and loops ```tsx // components/demo/video/video-autoplay-loop.tsx import { Video } from '@/components/ui/video'; import React from 'react'; export function VideoAutoplayLoop() { return ( <Video source={{ uri: 'https://ui.ahmedbna.com/', }} style={{ width: '100%', height: 180, borderRadius: 8, }} autoPlay={true} loop={true} muted={true} showControls={true} contentFit='cover' /> ); } ``` #### Different Sources **Example:** Video players with different source types ```tsx // components/demo/video/video-sources.tsx import { Text } from '@/components/ui/text'; import { Video } from '@/components/ui/video'; import { View } from '@/components/ui/view'; import React from 'react'; export function VideoSources() { const videoSources = [ { title: 'MP4 Source', uri: 'https://ui.ahmedbna.com/', }, { title: 'Alternative MP4', uri: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4', }, ]; return ( <View style={{ gap: 16 }}> {videoSources.map((source, index) => ( <View key={index} style={{ gap: 8 }}> <Text variant='body' style={{ fontWeight: '600' }}> {source.title} </Text> <Video source={{ uri: source.uri }} style={{ width: '100%', height: 160, borderRadius: 8, }} autoPlay={false} showControls={true} /> </View> ))} </View> ); } ``` #### Gesture Controls **Example:** Video player with tap-to-play and seek gestures ```tsx // components/demo/video/video-gestures.tsx import { Text } from '@/components/ui/text'; import { Video } from '@/components/ui/video'; import { View } from '@/components/ui/view'; import React from 'react'; export function VideoGestures() { return ( <View style={{ gap: 12 }}> <Text variant='body' style={{ fontSize: 14, opacity: 0.8 }}> Tap center to play/pause • Tap left to seek back • Tap right to seek forward </Text> <Video source={{ uri: 'https://ui.ahmedbna.com/', }} style={{ width: '100%', height: 220, borderRadius: 12, }} seekBy={10} autoPlay={false} showControls={true} onPlaybackStatusUpdate={(status) => { // Handle playback updates }} /> <Text variant='caption' style={{ textAlign: 'center', opacity: 0.6 }}> Try the gesture controls! This video seeks by 10 seconds. </Text> </View> ); } ``` #### Content Fit Options **Example:** Videos with different content fitting options ```tsx // components/demo/video/video-content-fit.tsx import { Text } from '@/components/ui/text'; import { Video } from '@/components/ui/video'; import { View } from '@/components/ui/view'; import React from 'react'; export function VideoContentFit() { const contentFitOptions: Array<{ mode: 'contain' | 'cover' | 'fill'; description: string; }> = [ { mode: 'contain', description: 'Fit entirely within bounds' }, { mode: 'cover', description: 'Fill bounds, may crop' }, { mode: 'fill', description: 'Stretch to fill bounds' }, ]; return ( <View style={{ gap: 16 }}> {contentFitOptions.map((option, index) => ( <View key={index} style={{ gap: 8 }}> <View> <Text variant='body' style={{ fontWeight: '600' }}> {option.mode.charAt(0).toUpperCase() + option.mode.slice(1)} </Text> <Text variant='caption' style={{ opacity: 0.7 }}> {option.description} </Text> </View> <Video source={{ uri: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/TearsOfSteel.mp4', }} style={{ width: '100%', height: 120, borderRadius: 8, backgroundColor: '#f0f0f0', }} contentFit={option.mode} autoPlay={false} showControls={true} muted={true} /> </View> ))} </View> ); } ``` ## API Reference ### Video The main video player component with customizable controls and features. | Prop | Type | Default | Description | | ------------------------ | --------------------------------- | --------- | ------------------------------------------------- | | `source` | `VideoSource` | - | The video source (required). | | `style` | `ViewStyle` | - | Additional styles for the video container. | | `seekBy` | `number` | `2` | Seconds to seek by on double tap. | | `autoPlay` | `boolean` | `false` | Whether to auto-play the video. | | `loop` | `boolean` | `false` | Whether to loop the video. | | `muted` | `boolean` | `false` | Whether to start muted. | | `nativeControls` | `boolean` | `false` | Use native video controls instead of custom ones. | | `showControls` | `boolean` | `true` | Whether to show custom controls. | | `allowsFullscreen` | `boolean` | `true` | Allow fullscreen mode. | | `allowsPictureInPicture` | `boolean` | `true` | Allow picture-in-picture mode. | | `contentFit` | `'contain' \| 'cover' \| 'fill'` | `'cover'` | How the video should fit within its container. | | `onLoad` | `() => void` | - | Callback when video is loaded. | | `onError` | `(error: any) => void` | - | Callback when an error occurs. | | `onPlaybackStatusUpdate` | `(status: any) => void` | - | Callback for playback status updates. | | `onFullscreenUpdate` | `(isFullscreen: boolean) => void` | - | Callback when fullscreen state changes. | | `subtitles` | `Array<Subtitle>` | `[]` | Array of subtitle objects. | ### VideoRef Reference methods available on the Video component. | Method | Type | Description | | ---------------- | --------------------------- | ---------------------------------------- | | `play` | `() => void` | Start playing the video. | | `pause` | `() => void` | Pause the video. | | `seekTo` | `(seconds: number) => void` | Seek to a specific time in seconds. | | `setVolume` | `(volume: number) => void` | Set the volume (0-1). | | `getCurrentTime` | `() => number` | Get the current playback time. | | `getDuration` | `() => number` | Get the total duration of the video. | | `isPlaying` | `() => boolean` | Check if the video is currently playing. | | `isMuted` | `() => boolean` | Check if the video is muted. | ### Subtitle Subtitle object structure for the subtitles prop. | Property | Type | Description | | -------- | -------- | --------------------------------------- | | `start` | `number` | Start time in seconds for the subtitle. | | `end` | `number` | End time in seconds for the subtitle. | | `text` | `string` | The subtitle text to display. | ## Gestures The Video component supports several gesture interactions: - **Single tap (center)**: Play/pause toggle - **Single tap (left side)**: Seek backward by `seekBy` seconds - **Single tap (right side)**: Seek forward by `seekBy` seconds - **Progress bar tap**: Seek to specific position ## Features ### Custom Controls - Play/pause button with visual feedback - Progress bar with seek functionality - Time display (current/total) - Mute/unmute toggle - Auto-hide controls after 3 seconds ### Subtitle Support - Display subtitles based on current playback time - Customizable subtitle styling - Automatic subtitle timing ### Error Handling - Graceful error handling with fallbacks - Console logging for debugging - Error callbacks for custom handling ### Accessibility The Video component is built with accessibility in mind: - Touch targets meet minimum size requirements - Clear visual feedback for interactions - Subtitle support for hearing accessibility - Proper semantic structure for screen readers ## Performance Considerations - Efficient playback status updates (100ms intervals) - Optimized gesture handling - Memory cleanup on component unmount - Smooth animations with native driver when possible <!-- ---------------------------------------------------------------------- --> # View > A foundational View component with transparent background and ref forwarding support. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/components/view - Markdown: https://ui.ahmedbna.com/docs/components/view.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/view.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/view.json - Install: `npx bna-ui add view` - Preview recording: https://demo.ahmedbna.com/0329-view-demo.PNG --- **Example:** Basic view container with content ```tsx // components/demo/view/view-demo.tsx import { View } from '@/components/ui/view'; import { BORDER_RADIUS } from '@/theme/globals'; export function ViewDemo() { return ( <View style={{ backgroundColor: 'red', alignItems: 'center', justifyContent: 'center', height: 200, width: 200, borderRadius: BORDER_RADIUS, }} /> ); } ``` ## Installation ### CLI ```bash npx bna-ui add view ``` ### Manual **1.** This component uses React Native's built-in View. **2.** Copy and paste the following code into your project. ```tsx // components/ui/view.tsx import { forwardRef, memo } from 'react'; import { View as RNView, type ViewProps } from 'react-native'; export const View = memo( forwardRef<RNView, ViewProps>(({ style, ...otherProps }, ref) => { return ( <RNView ref={ref} style={[{ backgroundColor: 'transparent' }, style]} {...otherProps} /> ); }) ); View.displayName = 'View'; ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { View } from '@/components/ui/view'; ``` ```tsx <View style={{ padding: 16 }}> <Text>Content inside view</Text> </View> ``` ## Examples #### Default **Example:** Basic view container with content ```tsx // components/demo/view/view-demo.tsx import { View } from '@/components/ui/view'; import { BORDER_RADIUS } from '@/theme/globals'; export function ViewDemo() { return ( <View style={{ backgroundColor: 'red', alignItems: 'center', justifyContent: 'center', height: 200, width: 200, borderRadius: BORDER_RADIUS, }} /> ); } ``` ## API Reference ### View A wrapper around React Native's View with consistent styling defaults and ref forwarding. | Prop | Type | Default | Description | | ---------- | ------------------- | ------------------------------------ | ---------------------------------------- | | `style` | `ViewStyle` | `{ backgroundColor: 'transparent' }` | Styles for the view container. | | `children` | `ReactNode` | - | Content to display inside the view. | | `ref` | `React.Ref<RNView>` | - | Ref to the underlying React Native View. | ### Additional Props The View component accepts all props from React Native's `View` component. ## Ref Forwarding The component uses `forwardRef` to provide access to the underlying View: <!-- ---------------------------------------------------------------------- --> # Charts > Here you can find all the charts available in the library. We are working on adding more charts. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/charts - Markdown: https://ui.ahmedbna.com/docs/charts.md --- - [Area Chart](/docs/charts/area-chart) - [Bar Chart](/docs/charts/bar-chart) - [Bubble Chart](/docs/charts/bubble-chart) - [Candlestick Chart](/docs/charts/candlestick-chart) - [Chart Container](/docs/charts/chart-container) - [Column Chart](/docs/charts/column-chart) - [Doughnut Chart](/docs/charts/doughnut-chart) - [Heatmap Chart](/docs/charts/heatmap-chart) - [Line Chart](/docs/charts/line-chart) - [Pie Chart](/docs/charts/pie-chart) - [Polar Area Chart](/docs/charts/polar-area-chart) - [Progress Ring Chart](/docs/charts/progress-ring-chart) - [Radar Chart](/docs/charts/radar-chart) - [Radial Bar Chart](/docs/charts/radial-bar-chart) - [Scatter Chart](/docs/charts/scatter-chart) - [Stacked Area Chart](/docs/charts/stacked-area-chart) - [Stacked Bar Chart](/docs/charts/stacked-bar-chart) - [TreeMap Chart](/docs/charts/treemap-chart) <!-- ---------------------------------------------------------------------- --> # Area Chart > A customizable area chart component with gradient fills and smooth animations. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/charts/area-chart - Markdown: https://ui.ahmedbna.com/docs/charts/area-chart.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/area-chart.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/area-chart.json - Install: `npx bna-ui add area-chart` - npm dependencies: `react-native-gesture-handler`, `react-native-reanimated`, `react-native-svg`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `line-chart` - Preview recording: https://demo.ahmedbna.com/0330-area-chart-demo.mov --- **Example:** An area chart with gradient fill and smooth animations ```tsx // components/demo/charts/area-chart/area-chart-demo.tsx import { AreaChart } from '@/components/charts/area-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const sampleData = [ { x: 'Jan', y: 100, label: 'January' }, { x: 'Feb', y: 120, label: 'February' }, { x: 'Mar', y: 90, label: 'March' }, { x: 'Apr', y: 140, label: 'April' }, { x: 'May', y: 110, label: 'May' }, { x: 'Jun', y: 130, label: 'June' }, ]; export function AreaChartDemo() { return ( <ChartContainer title='Website Traffic' description='Daily visitors with gradient fill' > <AreaChart data={sampleData} config={{ height: 200, showGrid: true, showLabels: false, animated: true, duration: 1800, gradient: true, }} /> </ChartContainer> ); } ``` ## Installation ### CLI ```bash npx bna-ui add area-chart ``` ### Manual **1.** Install the required dependencies. ```bash npm install react-native-svg react-native-reanimated react-native-worklets react-native-gesture-handler ``` **2.** Copy and paste the following code into your project. ```tsx // components/charts/area-chart.tsx import { LineChart } from '@/components/charts/line-chart'; import React from 'react'; import { ViewStyle } from 'react-native'; interface ChartConfig { width?: number; height?: number; padding?: number; showGrid?: boolean; showLabels?: boolean; animated?: boolean; duration?: number; gradient?: boolean; interactive?: boolean; showYLabels?: boolean; yLabelCount?: number; yAxisWidth?: number; } interface ChartDataPoint { x: string | number; y: number; label?: string; } type Props = { data: ChartDataPoint[]; config?: ChartConfig; style?: ViewStyle; }; export const AreaChart = React.memo(({ data, config = {}, style }: Props) => { return ( <LineChart data={data} config={{ ...config, gradient: true }} style={style} /> ); }); ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { AreaChart } from '@/components/charts/area-chart'; ``` ```tsx const data = [ { x: 'Jan', y: 100, label: 'January' }, { x: 'Feb', y: 120, label: 'February' }, { x: 'Mar', y: 90, label: 'March' }, { x: 'Apr', y: 140, label: 'April' }, ]; <AreaChart data={data} config={{ height: 200, showGrid: true, showLabels: true, animated: true, }} />; ``` ## Examples #### Basic Area Chart **Example:** An area chart with gradient fill and smooth animations ```tsx // components/demo/charts/area-chart/area-chart-demo.tsx import { AreaChart } from '@/components/charts/area-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const sampleData = [ { x: 'Jan', y: 100, label: 'January' }, { x: 'Feb', y: 120, label: 'February' }, { x: 'Mar', y: 90, label: 'March' }, { x: 'Apr', y: 140, label: 'April' }, { x: 'May', y: 110, label: 'May' }, { x: 'Jun', y: 130, label: 'June' }, ]; export function AreaChartDemo() { return ( <ChartContainer title='Website Traffic' description='Daily visitors with gradient fill' > <AreaChart data={sampleData} config={{ height: 200, showGrid: true, showLabels: false, animated: true, duration: 1800, gradient: true, }} /> </ChartContainer> ); } ``` #### Interactive Area Chart **Example:** An interactive area chart with touch gestures ```tsx // components/demo/charts/area-chart/area-chart-interactive.tsx import { AreaChart } from '@/components/charts/area-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const sampleData = [ { x: 'Jan', y: 4000, label: 'January' }, { x: 'Feb', y: 3000, label: 'February' }, { x: 'Mar', y: 5000, label: 'March' }, { x: 'Apr', y: 4500, label: 'April' }, { x: 'May', y: 6000, label: 'May' }, { x: 'Jun', y: 7200, label: 'June' }, { x: 'Jul', y: 6800, label: 'July' }, ]; export function AreaChartInteractive() { return ( <ChartContainer title='Interactive User Engagement' description='Touch to explore monthly user activity' > <AreaChart data={sampleData} config={{ height: 250, showGrid: true, showLabels: true, animated: true, duration: 1500, interactive: true, showYLabels: true, yLabelCount: 5, }} /> </ChartContainer> ); } ``` #### Styled Area Chart **Example:** A customized area chart with custom styling ```tsx // components/demo/charts/area-chart/area-chart-styled.tsx import { AreaChart } from '@/components/charts/area-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import { useColor } from '@/hooks/useColor'; import React from 'react'; const sampleData = [ { x: 'Week 1', y: 850, label: 'Week 1' }, { x: 'Week 2', y: 1200, label: 'Week 2' }, { x: 'Week 3', y: 980, label: 'Week 3' }, { x: 'Week 4', y: 1450, label: 'Week 4' }, { x: 'Week 5', y: 1100, label: 'Week 5' }, { x: 'Week 6', y: 1650, label: 'Week 6' }, ]; export function AreaChartStyled() { const borderColor = useColor('border'); const backgroundColor = useColor('card'); return ( <ChartContainer title='Weekly Sales Volume' description='Styled area chart with custom appearance' style={{ borderWidth: 1, borderColor: borderColor, backgroundColor: backgroundColor, borderRadius: 16, shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.15, shadowRadius: 12, elevation: 6, margin: 8, }} > <AreaChart data={sampleData} config={{ height: 220, showGrid: true, showLabels: true, animated: true, duration: 1800, showYLabels: true, yLabelCount: 6, padding: 24, }} /> </ChartContainer> ); } ``` #### Large Area Chart **Example:** An area chart with large data ```tsx // components/demo/charts/area-chart/area-chart-large.tsx import { AreaChart } from '@/components/charts/area-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const sampleData = [ { x: 'Jan', y: 65000, label: 'January' }, { x: 'Feb', y: 80000, label: 'February' }, { x: 'Mar', y: 75000, label: 'March' }, { x: 'Apr', y: 95000, label: 'April' }, { x: 'May', y: 110000, label: 'May' }, { x: 'Jun', y: 125000, label: 'June' }, { x: 'Jul', y: 140000, label: 'July' }, { x: 'Aug', y: 135000, label: 'August' }, { x: 'Sep', y: 150000, label: 'September' }, { x: 'Oct', y: 165000, label: 'October' }, { x: 'Nov', y: 180000, label: 'November' }, { x: 'Dec', y: 195000, label: 'December' }, ]; export function AreaChartLarge() { return ( <ChartContainer title='Annual Revenue Growth' description='Comprehensive yearly performance data' > <AreaChart data={sampleData} config={{ height: 280, showGrid: true, showLabels: false, animated: true, duration: 2500, showYLabels: true, yLabelCount: 7, padding: 20, }} /> </ChartContainer> ); } ``` ## API Reference ### AreaChart A customizable area chart component with gradient fills and smooth animations. Built on top of the LineChart component with gradient enabled by default. | Prop | Type | Default | Description | | -------- | ------------------ | ------- | --------------------------------------------- | | `data` | `ChartDataPoint[]` | - | Array of data points to display on the chart. | | `config` | `ChartConfig` | `{}` | Configuration object for chart appearance. | | `style` | `ViewStyle` | - | Additional styles to apply to the chart. | ### ChartDataPoint | Prop | Type | Description | | ------- | ------------------ | ------------------------------------ | | `x` | `string \| number` | The x-axis value for the data point. | | `y` | `number` | The y-axis value for the data point. | | `label` | `string` | Optional label for the data point. | ### ChartConfig | Prop | Type | Default | Description | | ------------- | --------- | ------- | ---------------------------------------------------------- | | `width` | `number` | - | Fixed width of the chart (auto-sizes if omitted). | | `height` | `number` | `200` | Height of the chart. | | `padding` | `number` | `20` | Padding around the chart. | | `showGrid` | `boolean` | `true` | Whether to show grid lines. | | `showLabels` | `boolean` | `true` | Whether to show x-axis labels. | | `animated` | `boolean` | `true` | Whether to animate the chart on load. | | `duration` | `number` | `1000` | Animation duration in milliseconds. | | `gradient` | `boolean` | `true` | Whether to show gradient fill (always true for AreaChart). | | `interactive` | `boolean` | `false` | Whether to enable touch interactions. | | `showYLabels` | `boolean` | `true` | Whether to show y-axis labels. | | `yLabelCount` | `number` | `5` | Number of y-axis labels to display. | | `yAxisWidth` | `number` | `20` | Width allocated for y-axis labels. | ## Features - **Gradient Fill**: Beautiful gradient fill under the area by default - **Smooth Animations**: Built-in animations using React Native Reanimated - **Interactive Touch**: Optional touch gestures for data exploration - **Responsive Design**: Automatically adapts to container width - **Customizable Grid**: Optional grid lines for better readability - **Curved Lines**: Smooth bezier curves between data points - **Smart Formatting**: Automatic number formatting (K, M suffixes) - **Theme Integration**: Uses theme colors for consistent styling ## Differences from LineChart The AreaChart component is essentially a LineChart with the `gradient` property automatically set to `true`. This creates a filled area under the line with a gradient effect that enhances data visualization for cumulative or volume-based data. ## Use Cases Area charts are particularly effective for: - **Time Series Data**: Showing trends over time with emphasis on magnitude - **Cumulative Values**: Displaying running totals or accumulated values - **Volume Metrics**: Representing quantities like traffic, sales, or usage - **Comparative Analysis**: Highlighting the "area under the curve" ## Accessibility The AreaChart component inherits all accessibility features from LineChart: - Semantic SVG structure for screen readers - Proper contrast ratios for visual elements - Touch targets meet minimum size requirements - Supports dynamic text sizing - Keyboard navigation support (when interactive) ## Performance The component is optimized for performance: - Uses React Native Reanimated for smooth 60fps animations - Efficient SVG rendering with minimal re-renders - Gesture handling optimized for touch interactions - Automatic cleanup of animation values <!-- ---------------------------------------------------------------------- --> # Bar Chart > A customizable bar chart component with smooth animations and interactive features. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/charts/bar-chart - Markdown: https://ui.ahmedbna.com/docs/charts/bar-chart.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/bar-chart.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/bar-chart.json - Install: `npx bna-ui add bar-chart` - npm dependencies: `react-native-reanimated`, `react-native-svg`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals` - Preview recording: https://demo.ahmedbna.com/0334-bar-chart-demo.mov --- **Example:** A basic bar chart with smooth animations and rounded corners ```tsx // components/demo/charts/bar-chart/bar-chart-demo.tsx import { ChartContainer } from '@/components/charts/chart-container'; import { BarChart } from '@/components/charts/bar-chart'; import React from 'react'; const sampleData = [ { label: 'Jan', value: 65, color: '#3b82f6' }, { label: 'Feb', value: 78, color: '#ef4444' }, { label: 'Mar', value: 52, color: '#10b981' }, { label: 'Apr', value: 91, color: '#f59e0b' }, { label: 'May', value: 73, color: '#8b5cf6' }, { label: 'Jun', value: 85, color: '#06b6d4' }, ]; export function BarChartDemo() { return ( <ChartContainer title='Monthly Sales' description='Product sales performance by month' > <BarChart data={sampleData} config={{ height: 220, showLabels: true, animated: true, duration: 1000, }} /> </ChartContainer> ); } ``` ## Installation ### CLI ```bash npx bna-ui add bar-chart ``` ### Manual **1.** Install the required dependencies. ```bash npm install react-native-svg react-native-reanimated react-native-worklets ``` **2.** Copy and paste the following code into your project. ```tsx // components/charts/bar-chart.tsx import { useColor } from '@/hooks/useColor'; import React, { useEffect, useState } from 'react'; import { LayoutChangeEvent, View, ViewStyle } from 'react-native'; import Animated, { SharedValue, useAnimatedProps, useSharedValue, withTiming, } from 'react-native-reanimated'; import Svg, { G, Line, Rect, Text as SvgText } from 'react-native-svg'; // Animated SVG Components const AnimatedRect = Animated.createAnimatedComponent(Rect); type AnimatedBarProps = { x: number; width: number; barHeight: number; bottomY: number; fill: string; animationProgress: SharedValue<number>; }; // Per-item hook must live in its own mounted subcomponent, not in the // parent's .map() body — calling useAnimatedProps per loop iteration // violates Rules of Hooks the moment data.length changes. const AnimatedBar = React.memo( ({ x, width, barHeight, bottomY, fill, animationProgress, }: AnimatedBarProps) => { const barAnimatedProps = useAnimatedProps(() => ({ height: animationProgress.value * barHeight, y: bottomY - animationProgress.value * barHeight, })); return ( <AnimatedRect x={x} width={width} fill={fill} rx={4} animatedProps={barAnimatedProps} /> ); } ); interface ChartConfig { width?: number; height?: number; padding?: number; showGrid?: boolean; showLabels?: boolean; animated?: boolean; duration?: number; } interface ChartDataPoint { label: string; value: number; color?: string; } type Props = { data: ChartDataPoint[]; config?: ChartConfig; style?: ViewStyle; }; export const BarChart = ({ data, config = {}, style }: Props) => { const [containerWidth, setContainerWidth] = useState(300); const { height = 200, padding = 20, showGrid = false, showLabels = true, animated = true, duration = 800, } = config; // Use measured width or fallback to config width or default const chartWidth = containerWidth || config.width || 300; const primaryColor = useColor('primary'); const mutedColor = useColor('mutedForeground'); const animationProgress = useSharedValue(0); const handleLayout = (event: LayoutChangeEvent) => { const { width: measuredWidth } = event.nativeEvent.layout; if (measuredWidth > 0) { setContainerWidth(measuredWidth); } }; useEffect(() => { if (animated) { animationProgress.value = withTiming(1, { duration }); } else { animationProgress.value = 1; } }, [data, animated, duration]); if (!data.length) return null; const maxValue = Math.max(...data.map((d) => d.value)); if (maxValue === 0) return null; const innerChartWidth = chartWidth - padding * 2; const chartHeight = height - padding * 2; const barWidth = (innerChartWidth / data.length) * 0.8; const barSpacing = (innerChartWidth / data.length) * 0.2; return ( <View style={[{ width: '100%', height }, style]} onLayout={handleLayout} accessibilityRole='image' accessibilityLabel={`Bar chart with ${data.length} bars, maximum value ${Math.round(maxValue)}`} > <Svg width={chartWidth} height={height}> {/* Grid lines */} {showGrid && ( <G> {[0, 0.25, 0.5, 0.75, 1].map((ratio, index) => ( <Line key={`grid-${index}`} x1={padding} y1={padding + ratio * chartHeight} x2={chartWidth - padding} y2={padding + ratio * chartHeight} stroke={mutedColor} strokeWidth={0.5} opacity={0.3} /> ))} </G> )} {data.map((item, index) => { const barHeight = (item.value / maxValue) * chartHeight; const x = padding + index * (barWidth + barSpacing) + barSpacing / 2; const y = height - padding - barHeight; return ( <G key={`bar-${index}`}> <AnimatedBar x={x} width={barWidth} barHeight={barHeight} bottomY={height - padding} fill={item.color || primaryColor} animationProgress={animationProgress} /> {showLabels && ( <> <SvgText x={x + barWidth / 2} y={height - 5} textAnchor='middle' fontSize={12} fill={mutedColor} > {item.label} </SvgText> <SvgText x={x + barWidth / 2} y={y - 5} textAnchor='middle' fontSize={11} fill={mutedColor} fontWeight='600' > {item.value} </SvgText> </> )} </G> ); })} </Svg> </View> ); }; ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { BarChart } from '@/components/charts/bar-chart'; ``` ```tsx const data = [ { label: 'Jan', value: 100, color: '#3b82f6' }, { label: 'Feb', value: 120, color: '#ef4444' }, { label: 'Mar', value: 90, color: '#10b981' }, { label: 'Apr', value: 140, color: '#f59e0b' }, ]; <BarChart data={data} config={{ height: 200, showLabels: true, animated: true, }} />; ``` ## Examples #### Basic Bar Chart **Example:** A basic bar chart with smooth animations and rounded corners ```tsx // components/demo/charts/bar-chart/bar-chart-demo.tsx import { ChartContainer } from '@/components/charts/chart-container'; import { BarChart } from '@/components/charts/bar-chart'; import React from 'react'; const sampleData = [ { label: 'Jan', value: 65, color: '#3b82f6' }, { label: 'Feb', value: 78, color: '#ef4444' }, { label: 'Mar', value: 52, color: '#10b981' }, { label: 'Apr', value: 91, color: '#f59e0b' }, { label: 'May', value: 73, color: '#8b5cf6' }, { label: 'Jun', value: 85, color: '#06b6d4' }, ]; export function BarChartDemo() { return ( <ChartContainer title='Monthly Sales' description='Product sales performance by month' > <BarChart data={sampleData} config={{ height: 220, showLabels: true, animated: true, duration: 1000, }} /> </ChartContainer> ); } ``` #### Sample Bar Chart **Example:** A sample bar chart with custom colors ```tsx // components/demo/charts/bar-chart/bar-chart-sample.tsx import { ChartContainer } from '@/components/charts/chart-container'; import { BarChart } from '@/components/charts/bar-chart'; import React from 'react'; const sampleData = [ { label: 'Product A', value: 120, color: '#3b82f6' }, { label: 'Product B', value: 98, color: '#ef4444' }, { label: 'Product C', value: 86, color: '#10b981' }, { label: 'Product D', value: 74, color: '#f59e0b' }, { label: 'Product E', value: 65, color: '#8b5cf6' }, ]; export function BarChartSample() { return ( <ChartContainer title='Product Performance' description='Sales performance by product category' > <BarChart data={sampleData} config={{ height: 250, showLabels: true, animated: true, duration: 1200, }} /> </ChartContainer> ); } ``` #### Minimal Bar Chart **Example:** A minimal bar chart without labels ```tsx // components/demo/charts/bar-chart/bar-chart-minimal.tsx import { BarChart } from '@/components/charts/bar-chart'; import React from 'react'; const sampleData = [ { label: 'A', value: 30 }, { label: 'B', value: 50 }, { label: 'C', value: 25 }, { label: 'D', value: 70 }, { label: 'E', value: 45 }, { label: 'F', value: 60 }, ]; export function BarChartMinimal() { return ( <BarChart data={sampleData} config={{ height: 150, showLabels: false, animated: true, duration: 600, padding: 10, }} /> ); } ``` ## API Reference ### BarChart A customizable bar chart component with smooth animations and rounded corners. | Prop | Type | Default | Description | | -------- | ------------------ | ------- | --------------------------------------------- | | `data` | `ChartDataPoint[]` | - | Array of data points to display on the chart. | | `config` | `ChartConfig` | `{}` | Configuration object for chart appearance. | | `style` | `ViewStyle` | - | Additional styles to apply to the chart. | ### ChartDataPoint | Prop | Type | Description | | ------- | -------- | ---------------------------------- | | `label` | `string` | The label for the bar. | | `value` | `number` | The value of the bar. | | `color` | `string` | Optional custom color for the bar. | ### ChartConfig | Prop | Type | Default | Description | | ------------ | --------- | ------- | ------------------------------------------------- | | `width` | `number` | - | Fixed width of the chart (auto-sizes if omitted). | | `height` | `number` | `200` | Height of the chart. | | `padding` | `number` | `20` | Padding around the chart. | | `showGrid` | `boolean` | `false` | Whether to show grid lines. | | `showLabels` | `boolean` | `true` | Whether to show labels on bars. | | `animated` | `boolean` | `true` | Whether to animate the chart on load. | | `duration` | `number` | `800` | Animation duration in milliseconds. | ## Features - **Smooth Animations**: Built-in animations using React Native Reanimated - **Rounded Corners**: Customizable corner radius for modern appearance - **Responsive Design**: Automatically adapts to container width - **Custom Colors**: Individual bar colors or color scale support - **Value Labels**: Optional value display on top of bars - **Auto-scaling**: Automatic calculation of bar heights and spacing - **Theme Integration**: Uses theme colors for consistent styling ## Accessibility The BarChart component is built with accessibility in mind: - Semantic SVG structure for screen readers - Proper contrast ratios for visual elements - Touch targets meet minimum size requirements - Supports dynamic text sizing - Clear visual hierarchy with labels and values ## Performance The component is optimized for performance: - Uses React Native Reanimated for smooth 60fps animations - Efficient SVG rendering with minimal re-renders - Automatic cleanup of animation values - Optimized bar spacing calculations <!-- ---------------------------------------------------------------------- --> # Bubble Chart > A customizable bubble chart component with animations and size mapping. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/charts/bubble-chart - Markdown: https://ui.ahmedbna.com/docs/charts/bubble-chart.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/bubble-chart.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/bubble-chart.json - Install: `npx bna-ui add bubble-chart` - npm dependencies: `react-native-reanimated`, `react-native-svg`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals` - Preview recording: https://demo.ahmedbna.com/0337-bubble-chart-demo.mov --- **Example:** A basic bubble chart with animated bubbles and grid lines ```tsx // components/demo/charts/bubble-chart/bubble-chart-demo.tsx import { ChartContainer } from '@/components/charts/chart-container'; import { BubbleChart } from '@/components/charts/bubble-chart'; import React from 'react'; const sampleData = [ { x: 10, y: 20, size: 15, label: 'A' }, { x: 25, y: 30, size: 25, label: 'B' }, { x: 40, y: 15, size: 30, label: 'C' }, { x: 35, y: 45, size: 20, label: 'D' }, { x: 60, y: 25, size: 18, label: 'E' }, { x: 50, y: 40, size: 22, label: 'F' }, { x: 15, y: 35, size: 28, label: 'G' }, { x: 70, y: 50, size: 16, label: 'H' }, ]; export function BubbleChartDemo() { return ( <ChartContainer title='Performance vs Efficiency' description='Team performance metrics with bubble sizes representing team size' > <BubbleChart data={sampleData} config={{ height: 300, showGrid: true, showLabels: true, animated: true, duration: 1200, }} /> </ChartContainer> ); } ``` ## Installation ### CLI ```bash npx bna-ui add bubble-chart ``` ### Manual **1.** Install the required dependencies. ```bash npm install react-native-svg react-native-reanimated react-native-worklets ``` **2.** Copy and paste the following code into your project. ```tsx // components/charts/bubble-chart.tsx import { useColor } from '@/hooks/useColor'; import React, { useEffect, useState } from 'react'; import { LayoutChangeEvent, View, ViewStyle } from 'react-native'; import Animated, { SharedValue, useAnimatedProps, useSharedValue, withDelay, withSpring, withTiming, } from 'react-native-reanimated'; import Svg, { Circle, G, Line, Text as SvgText } from 'react-native-svg'; // Animated SVG Components const AnimatedCircle = Animated.createAnimatedComponent(Circle); type AnimatedBubbleProps = { cx: number; cy: number; radius: number; fill: string; index: number; animationProgress: SharedValue<number>; }; // Per-item hook must live in its own mounted subcomponent, not in the // parent's .map() body — calling useAnimatedProps per loop iteration // violates Rules of Hooks the moment data.length changes. const AnimatedBubble = React.memo( ({ cx, cy, radius, fill, index, animationProgress }: AnimatedBubbleProps) => { const bubbleAnimatedProps = useAnimatedProps(() => ({ opacity: animationProgress.value * 0.7, r: withDelay(index * 100, withSpring(animationProgress.value * radius)), })); return ( <AnimatedCircle cx={cx} cy={cy} fill={fill} animatedProps={bubbleAnimatedProps} /> ); } ); interface ChartConfig { width?: number; height?: number; padding?: number; showGrid?: boolean; showLabels?: boolean; animated?: boolean; duration?: number; } interface BubbleChartDataPoint { x: number; y: number; size: number; label?: string; color?: string; } type Props = { data: BubbleChartDataPoint[]; config?: ChartConfig; style?: ViewStyle; }; export const BubbleChart = ({ data, config = {}, style }: Props) => { const [containerWidth, setContainerWidth] = useState(300); const { height = 200, padding = 20, showGrid = true, showLabels = true, animated = true, duration = 800, } = config; const chartWidth = containerWidth || config.width || 300; const primaryColor = useColor('primary'); const mutedColor = useColor('mutedForeground'); const animationProgress = useSharedValue(0); const handleLayout = (event: LayoutChangeEvent) => { const { width: measuredWidth } = event.nativeEvent.layout; if (measuredWidth > 0) { setContainerWidth(measuredWidth); } }; useEffect(() => { if (animated) { animationProgress.value = withTiming(1, { duration }); } else { animationProgress.value = 1; } }, [data, animated, duration]); if (!data.length) return null; const maxX = Math.max(...data.map((d) => d.x)); const minX = Math.min(...data.map((d) => d.x)); const maxY = Math.max(...data.map((d) => d.y)); const minY = Math.min(...data.map((d) => d.y)); const maxSize = Math.max(...data.map((d) => d.size)); const xRange = maxX - minX || 1; const yRange = maxY - minY || 1; const innerChartWidth = chartWidth - padding * 2; const chartHeight = height - padding * 2; const colors = [ primaryColor, useColor('blue'), useColor('green'), useColor('orange'), useColor('purple'), useColor('pink'), ]; // Convert data to screen coordinates const bubbles = data.map((point, index) => ({ x: padding + ((point.x - minX) / xRange) * innerChartWidth, y: padding + ((maxY - point.y) / yRange) * chartHeight, radius: (point.size / maxSize) * 20 + 5, // Scale bubble size color: point.color || colors[index % colors.length], label: point.label, })); return ( <View style={[{ width: '100%', height }, style]} onLayout={handleLayout} accessibilityRole='image' accessibilityLabel={`Bubble chart with ${data.length} bubbles, x from ${Math.round(minX)} to ${Math.round(maxX)}, y from ${Math.round(minY)} to ${Math.round(maxY)}`} > <Svg width={chartWidth} height={height}> {/* Grid lines */} {showGrid && ( <G> {[0, 0.25, 0.5, 0.75, 1].map((ratio, index) => ( <G key={`grid-${index}`}> <Line x1={padding} y1={padding + ratio * chartHeight} x2={chartWidth - padding} y2={padding + ratio * chartHeight} stroke={mutedColor} strokeWidth={0.5} opacity={0.3} /> <Line x1={padding + ratio * innerChartWidth} y1={padding} x2={padding + ratio * innerChartWidth} y2={height - padding} stroke={mutedColor} strokeWidth={0.5} opacity={0.3} /> </G> ))} </G> )} {/* Bubbles */} {bubbles.map((bubble, index) => { return ( <G key={`bubble-${index}`}> <AnimatedBubble cx={bubble.x} cy={bubble.y} radius={bubble.radius} fill={bubble.color} index={index} animationProgress={animationProgress} /> {showLabels && bubble.label && ( <SvgText x={bubble.x} y={bubble.y} textAnchor='middle' fontSize={10} fill='#FFFFFF' fontWeight='600' alignmentBaseline='middle' > {bubble.label} </SvgText> )} </G> ); })} {/* Axis labels */} <G> {/* X-axis labels */} {[minX, (minX + maxX) / 2, maxX].map((value, index) => ( <SvgText key={`x-label-${index}`} x={padding + (index * innerChartWidth) / 2} y={height - 5} textAnchor='middle' fontSize={12} fill={mutedColor} > {Math.round(value)} </SvgText> ))} {/* Y-axis labels */} {[maxY, (minY + maxY) / 2, minY].map((value, index) => ( <SvgText key={`y-label-${index}`} x={15} y={padding + (index * chartHeight) / 2} textAnchor='middle' fontSize={12} fill={mutedColor} alignmentBaseline='middle' > {Math.round(value)} </SvgText> ))} </G> </Svg> </View> ); }; ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { BubbleChart } from '@/components/charts/bubble-chart'; ``` ```tsx const data = [ { x: 10, y: 20, size: 15, label: 'A', color: '#FF6B6B' }, { x: 25, y: 30, size: 25, label: 'B', color: '#4ECDC4' }, { x: 40, y: 15, size: 30, label: 'C', color: '#45B7D1' }, { x: 35, y: 45, size: 20, label: 'D', color: '#96CEB4' }, ]; <BubbleChart data={data} config={{ height: 300, showGrid: true, showLabels: true, animated: true, }} />; ``` ## Examples #### Basic Bubble Chart **Example:** A basic bubble chart with animated bubbles and grid lines ```tsx // components/demo/charts/bubble-chart/bubble-chart-demo.tsx import { ChartContainer } from '@/components/charts/chart-container'; import { BubbleChart } from '@/components/charts/bubble-chart'; import React from 'react'; const sampleData = [ { x: 10, y: 20, size: 15, label: 'A' }, { x: 25, y: 30, size: 25, label: 'B' }, { x: 40, y: 15, size: 30, label: 'C' }, { x: 35, y: 45, size: 20, label: 'D' }, { x: 60, y: 25, size: 18, label: 'E' }, { x: 50, y: 40, size: 22, label: 'F' }, { x: 15, y: 35, size: 28, label: 'G' }, { x: 70, y: 50, size: 16, label: 'H' }, ]; export function BubbleChartDemo() { return ( <ChartContainer title='Performance vs Efficiency' description='Team performance metrics with bubble sizes representing team size' > <BubbleChart data={sampleData} config={{ height: 300, showGrid: true, showLabels: true, animated: true, duration: 1200, }} /> </ChartContainer> ); } ``` #### Sample Bubble Chart **Example:** A sample bubble chart ```tsx // components/demo/charts/bubble-chart/bubble-chart-sample.tsx import { ChartContainer } from '@/components/charts/chart-container'; import { BubbleChart } from '@/components/charts/bubble-chart'; import React from 'react'; const sampleData = [ { x: 20, y: 30, size: 45, label: 'Sales', color: '#FF6B6B' }, { x: 35, y: 25, size: 35, label: 'Marketing', color: '#4ECDC4' }, { x: 50, y: 40, size: 25, label: 'Dev', color: '#45B7D1' }, { x: 65, y: 35, size: 30, label: 'Support', color: '#96CEB4' }, { x: 40, y: 50, size: 20, label: 'HR', color: '#FFEAA7' }, { x: 25, y: 45, size: 15, label: 'Finance', color: '#DDA0DD' }, ]; export function BubbleChartSample() { return ( <ChartContainer title='Department Analytics' description='Bubble chart showing department metrics' > <BubbleChart data={sampleData} config={{ height: 320, showGrid: true, showLabels: true, animated: true, duration: 1500, }} /> </ChartContainer> ); } ``` #### Styled Bubble Chart **Example:** A customized bubble chart with custom styling ```tsx // components/demo/charts/bubble-chart/bubble-chart-styled.tsx import { ChartContainer } from '@/components/charts/chart-container'; import { BubbleChart } from '@/components/charts/bubble-chart'; import { useColor } from '@/hooks/useColor'; import React from 'react'; const styledData = [ { x: 15, y: 25, size: 40, label: 'Q1', color: '#FF6B6B' }, { x: 30, y: 35, size: 50, label: 'Q2', color: '#4ECDC4' }, { x: 45, y: 30, size: 35, label: 'Q3', color: '#45B7D1' }, { x: 60, y: 45, size: 45, label: 'Q4', color: '#96CEB4' }, { x: 25, y: 50, size: 25, label: 'Bonus', color: '#FFEAA7' }, ]; export function BubbleChartStyled() { const backgroundColor = useColor('card'); return ( <ChartContainer title='Quarterly Revenue' description='Styled bubble chart with custom colors and enhanced visuals' > <BubbleChart data={styledData} config={{ height: 280, showGrid: true, showLabels: true, animated: true, duration: 1800, }} style={{ backgroundColor, borderRadius: 12, padding: 8, }} /> </ChartContainer> ); } ``` #### Minimal Bubble Chart **Example:** A minimal bubble chart without labels or grid ```tsx // components/demo/charts/bubble-chart/bubble-chart-minimal.tsx import { BubbleChart } from '@/components/charts/bubble-chart'; import React from 'react'; const minimalData = [ { x: 20, y: 30, size: 25 }, { x: 40, y: 45, size: 35 }, { x: 60, y: 25, size: 20 }, { x: 35, y: 55, size: 30 }, { x: 70, y: 40, size: 15 }, ]; export function BubbleChartMinimal() { return ( <BubbleChart data={minimalData} config={{ height: 200, showGrid: false, showLabels: false, animated: true, duration: 1000, padding: 10, }} /> ); } ``` ## API Reference ### BubbleChart A customizable bubble chart component with smooth animations. | Prop | Type | Default | Description | | -------- | ------------------------ | ------- | --------------------------------------------- | | `data` | `BubbleChartDataPoint[]` | - | Array of data points to display on the chart. | | `config` | `ChartConfig` | `{}` | Configuration object for chart appearance. | | `style` | `ViewStyle` | - | Additional styles to apply to the chart. | ### BubbleChartDataPoint | Prop | Type | Description | | ------- | -------- | ------------------------------------ | | `x` | `number` | The x-axis value for the data point. | | `y` | `number` | The y-axis value for the data point. | | `size` | `number` | The size value for the bubble. | | `label` | `string` | Optional label for the data point. | | `color` | `string` | Optional color for the bubble. | ### ChartConfig | Prop | Type | Default | Description | | ------------ | --------- | ------- | ------------------------------------------------- | | `width` | `number` | - | Fixed width of the chart (auto-sizes if omitted). | | `height` | `number` | `200` | Height of the chart. | | `padding` | `number` | `20` | Padding around the chart. | | `showGrid` | `boolean` | `true` | Whether to show grid lines. | | `showLabels` | `boolean` | `true` | Whether to show bubble labels. | | `animated` | `boolean` | `true` | Whether to animate the chart on load. | | `duration` | `number` | `800` | Animation duration in milliseconds. | ## Features - **Smooth Animations**: Built-in animations using React Native Reanimated - **Size Mapping**: Bubble sizes automatically scaled based on data values - **Responsive Design**: Automatically adapts to container width - **Customizable Grid**: Optional grid lines for better readability - **Color Customization**: Support for custom colors or automatic color assignment - **Staggered Animation**: Bubbles animate in sequence for visual appeal - **Theme Integration**: Uses theme colors for consistent styling ## Accessibility The BubbleChart component is built with accessibility in mind: - The chart's outer container exposes accessibilityRole="image" with a synthesized summary label (bubble count and x/y ranges) ## Performance The component is optimized for performance: - Uses React Native Reanimated for smooth 60fps animations - Efficient SVG rendering with minimal re-renders - Gesture handling optimized for touch interactions - Automatic cleanup of animation values - Staggered animations to prevent performance bottlenecks <!-- ---------------------------------------------------------------------- --> # Candlestick Chart > A customizable candlestick chart component with animations for financial data visualization. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/charts/candlestick-chart - Markdown: https://ui.ahmedbna.com/docs/charts/candlestick-chart.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/candlestick-chart.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/candlestick-chart.json - Install: `npx bna-ui add candlestick-chart` - npm dependencies: `react-native-reanimated`, `react-native-svg`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals` - Preview recording: https://demo.ahmedbna.com/0341-candlestick-chart-demo.MP4 --- **Example:** A basic candlestick chart with smooth animations and grid lines ```tsx // components/demo/charts/candlestick-chart/candlestick-chart-demo.tsx import { ChartContainer } from '@/components/charts/chart-container'; import { CandlestickChart } from '@/components/charts/candlestick-chart'; import React from 'react'; const sampleData = [ { date: 'Jan 1', open: 100, high: 120, low: 95, close: 110 }, { date: 'Jan 2', open: 110, high: 125, low: 105, close: 115 }, { date: 'Jan 3', open: 115, high: 130, low: 110, close: 125 }, { date: 'Jan 4', open: 125, high: 140, low: 120, close: 135 }, { date: 'Jan 5', open: 135, high: 145, low: 125, close: 128 }, { date: 'Jan 6', open: 128, high: 135, low: 118, close: 132 }, { date: 'Jan 7', open: 132, high: 142, low: 128, close: 138 }, { date: 'Jan 8', open: 138, high: 148, low: 132, close: 145 }, { date: 'Jan 9', open: 145, high: 155, low: 140, close: 150 }, { date: 'Jan 10', open: 150, high: 160, low: 145, close: 155 }, ]; export function CandlestickChartDemo() { return ( <ChartContainer title='Stock Price Movement' description='Daily OHLC data showing price trends over time' > <CandlestickChart data={sampleData} config={{ height: 220, showGrid: true, showLabels: true, animated: true, duration: 1200, }} /> </ChartContainer> ); } ``` ## Installation ### CLI ```bash npx bna-ui add candlestick-chart ``` ### Manual **1.** Install the required dependencies. ```bash npm install react-native-svg react-native-reanimated react-native-worklets ``` **2.** Copy and paste the following code into your project. ```tsx // components/charts/candlestick-chart.tsx import { useColor } from '@/hooks/useColor'; import React, { useEffect, useState } from 'react'; import { LayoutChangeEvent, View, ViewStyle } from 'react-native'; import Animated, { SharedValue, useAnimatedProps, useSharedValue, withTiming, } from 'react-native-reanimated'; import Svg, { G, Line, Rect, Text as SvgText } from 'react-native-svg'; // Animated SVG Components const AnimatedRect = Animated.createAnimatedComponent(Rect); const AnimatedLine = Animated.createAnimatedComponent(Line); type AnimatedCandleProps = { x: number; candleWidth: number; highY: number; lowY: number; bodyTop: number; bodyHeight: number; color: string; animationProgress: SharedValue<number>; }; // Per-item hooks must live in their own mounted subcomponent, not in the // parent's .map() body — calling useAnimatedProps per loop iteration // violates Rules of Hooks the moment data.length changes. Two hooks here // (wick + body), both owned by this one subcomponent instance. const AnimatedCandle = React.memo( ({ x, candleWidth, highY, lowY, bodyTop, bodyHeight, color, animationProgress, }: AnimatedCandleProps) => { const wickAnimatedProps = useAnimatedProps(() => ({ y1: highY, y2: lowY, opacity: animationProgress.value, })); const bodyAnimatedProps = useAnimatedProps(() => ({ height: animationProgress.value * bodyHeight, y: bodyTop, opacity: animationProgress.value, })); return ( <> <AnimatedLine x1={x + candleWidth / 2} x2={x + candleWidth / 2} stroke={color} strokeWidth={1} animatedProps={wickAnimatedProps} /> <AnimatedRect x={x} width={candleWidth} fill={color} stroke={color} strokeWidth={1} animatedProps={bodyAnimatedProps} /> </> ); } ); interface ChartConfig { width?: number; height?: number; padding?: number; showGrid?: boolean; showLabels?: boolean; animated?: boolean; duration?: number; } interface CandlestickDataPoint { date: string; open: number; high: number; low: number; close: number; } type Props = { data: CandlestickDataPoint[]; config?: ChartConfig; style?: ViewStyle; }; export const CandlestickChart = ({ data, config = {}, style }: Props) => { const [containerWidth, setContainerWidth] = useState(300); const { height = 200, padding = 20, showGrid = true, showLabels = true, animated = true, duration = 800, } = config; // Use measured width or fallback to config width or default const chartWidth = containerWidth || config.width || 300; const bullishColor = useColor('green'); const bearishColor = useColor('red'); const mutedColor = useColor('mutedForeground'); const animationProgress = useSharedValue(0); const handleLayout = (event: LayoutChangeEvent) => { const { width: measuredWidth } = event.nativeEvent.layout; if (measuredWidth > 0) { setContainerWidth(measuredWidth); } }; useEffect(() => { if (animated) { animationProgress.value = withTiming(1, { duration }); } else { animationProgress.value = 1; } }, [data, animated, duration]); if (!data.length) return null; const allValues = data.flatMap((d) => [d.open, d.high, d.low, d.close]); const maxValue = Math.max(...allValues); const minValue = Math.min(...allValues); const valueRange = maxValue - minValue || 1; const innerChartWidth = chartWidth - padding * 2; const chartHeight = height - padding * 2; const candleWidth = (innerChartWidth / data.length) * 0.6; const candleSpacing = (innerChartWidth / data.length) * 0.4; return ( <View style={[{ width: '100%', height }, style]} onLayout={handleLayout} accessibilityRole='image' accessibilityLabel={`Candlestick chart with ${data.length} candles, ranging from ${Math.round(minValue)} to ${Math.round(maxValue)}`} > <Svg width={chartWidth} height={height}> {/* Grid lines */} {showGrid && ( <G> {[0, 0.25, 0.5, 0.75, 1].map((ratio, index) => ( <Line key={`grid-${index}`} x1={padding} y1={padding + ratio * chartHeight} x2={chartWidth - padding} y2={padding + ratio * chartHeight} stroke={mutedColor} strokeWidth={0.5} opacity={0.3} /> ))} </G> )} {data.map((item, index) => { const isBullish = item.close >= item.open; const color = isBullish ? bullishColor : bearishColor; const x = padding + index * (candleWidth + candleSpacing) + candleSpacing / 2; const highY = padding + ((maxValue - item.high) / valueRange) * chartHeight; const lowY = padding + ((maxValue - item.low) / valueRange) * chartHeight; const openY = padding + ((maxValue - item.open) / valueRange) * chartHeight; const closeY = padding + ((maxValue - item.close) / valueRange) * chartHeight; const bodyTop = Math.min(openY, closeY); const bodyHeight = Math.abs(closeY - openY) || 1; return ( <G key={`candle-${index}`}> <AnimatedCandle x={x} candleWidth={candleWidth} highY={highY} lowY={lowY} bodyTop={bodyTop} bodyHeight={bodyHeight} color={color} animationProgress={animationProgress} /> {showLabels && index % Math.max(1, Math.floor(data.length / 5)) === 0 && ( <SvgText x={x + candleWidth / 2} y={height - 5} textAnchor='middle' fontSize={10} fill={mutedColor} > {item.date} </SvgText> )} </G> ); })} </Svg> </View> ); }; ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { CandlestickChart } from '@/components/charts/candlestick-chart'; ``` ```tsx const data = [ { date: 'Jan 1', open: 100, high: 120, low: 95, close: 110 }, { date: 'Jan 2', open: 110, high: 125, low: 105, close: 115 }, { date: 'Jan 3', open: 115, high: 130, low: 110, close: 125 }, { date: 'Jan 4', open: 125, high: 140, low: 120, close: 135 }, ]; <CandlestickChart data={data} config={{ height: 200, showGrid: true, showLabels: true, animated: true, }} />; ``` ## Examples #### Basic Candlestick Chart **Example:** A basic candlestick chart with smooth animations and grid lines ```tsx // components/demo/charts/candlestick-chart/candlestick-chart-demo.tsx import { ChartContainer } from '@/components/charts/chart-container'; import { CandlestickChart } from '@/components/charts/candlestick-chart'; import React from 'react'; const sampleData = [ { date: 'Jan 1', open: 100, high: 120, low: 95, close: 110 }, { date: 'Jan 2', open: 110, high: 125, low: 105, close: 115 }, { date: 'Jan 3', open: 115, high: 130, low: 110, close: 125 }, { date: 'Jan 4', open: 125, high: 140, low: 120, close: 135 }, { date: 'Jan 5', open: 135, high: 145, low: 125, close: 128 }, { date: 'Jan 6', open: 128, high: 135, low: 118, close: 132 }, { date: 'Jan 7', open: 132, high: 142, low: 128, close: 138 }, { date: 'Jan 8', open: 138, high: 148, low: 132, close: 145 }, { date: 'Jan 9', open: 145, high: 155, low: 140, close: 150 }, { date: 'Jan 10', open: 150, high: 160, low: 145, close: 155 }, ]; export function CandlestickChartDemo() { return ( <ChartContainer title='Stock Price Movement' description='Daily OHLC data showing price trends over time' > <CandlestickChart data={sampleData} config={{ height: 220, showGrid: true, showLabels: true, animated: true, duration: 1200, }} /> </ChartContainer> ); } ``` #### Sample Candlestick Chart **Example:** A candlestick chart showing weekly price movements ```tsx // components/demo/charts/candlestick-chart/candlestick-chart-sample.tsx import { ChartContainer } from '@/components/charts/chart-container'; import { CandlestickChart } from '@/components/charts/candlestick-chart'; import React from 'react'; const sampleData = [ { date: 'Week 1', open: 150, high: 165, low: 145, close: 160 }, { date: 'Week 2', open: 160, high: 175, low: 155, close: 170 }, { date: 'Week 3', open: 170, high: 185, low: 165, close: 180 }, { date: 'Week 4', open: 180, high: 195, low: 175, close: 190 }, { date: 'Week 5', open: 190, high: 205, low: 185, close: 200 }, { date: 'Week 6', open: 200, high: 215, low: 195, close: 210 }, { date: 'Week 7', open: 210, high: 225, low: 205, close: 220 }, { date: 'Week 8', open: 220, high: 235, low: 215, close: 230 }, ]; export function CandlestickChartSample() { return ( <ChartContainer title='Stock Chart' description='Explore weekly price movements' > <CandlestickChart data={sampleData} config={{ height: 250, showGrid: true, showLabels: true, animated: true, duration: 1500, }} /> </ChartContainer> ); } ``` #### Styled Candlestick Chart **Example:** A customized candlestick chart with custom colors ```tsx // components/demo/charts/candlestick-chart/candlestick-chart-styled.tsx import { ChartContainer } from '@/components/charts/chart-container'; import { CandlestickChart } from '@/components/charts/candlestick-chart'; import React from 'react'; const styledData = [ { date: 'Q1', open: 250, high: 280, low: 240, close: 275 }, { date: 'Q2', open: 275, high: 300, low: 260, close: 285 }, { date: 'Q3', open: 285, high: 320, low: 275, close: 310 }, { date: 'Q4', open: 310, high: 340, low: 295, close: 325 }, { date: 'Q1', open: 325, high: 350, low: 315, close: 340 }, { date: 'Q2', open: 340, high: 365, low: 330, close: 355 }, ]; export function CandlestickChartStyled() { return ( <ChartContainer title='Quarterly Performance' description='Custom styled candlestick chart with quarterly data' > <CandlestickChart data={styledData} config={{ height: 280, padding: 30, showGrid: true, showLabels: true, animated: true, duration: 2000, }} style={{ backgroundColor: 'rgba(0, 0, 0, 0.02)', borderRadius: 12, padding: 16, }} /> </ChartContainer> ); } ``` #### Minimal Candlestick Chart **Example:** A minimal candlestick chart without labels ```tsx // components/demo/charts/candlestick-chart/candlestick-chart-minimal.tsx import { CandlestickChart } from '@/components/charts/candlestick-chart'; import React from 'react'; const minimalData = [ { date: '10', open: 190, high: 205, low: 185, close: 200 }, { date: '8', open: 170, high: 185, low: 165, close: 180 }, { date: '9', open: 180, high: 195, low: 175, close: 170 }, { date: '7', open: 160, high: 175, low: 155, close: 150 }, { date: '6', open: 150, high: 165, low: 145, close: 160 }, { date: '4', open: 130, high: 145, low: 125, close: 140 }, { date: '2', open: 110, high: 125, low: 105, close: 120 }, { date: '3', open: 120, high: 135, low: 115, close: 130 }, { date: '5', open: 140, high: 155, low: 135, close: 150 }, { date: '1', open: 100, high: 115, low: 95, close: 90 }, ]; export function CandlestickChartMinimal() { return ( <CandlestickChart data={minimalData} config={{ height: 180, padding: 15, showGrid: false, showLabels: false, animated: true, duration: 1000, }} /> ); } ``` ## API Reference ### CandlestickChart A customizable candlestick chart component for financial data visualization with smooth animations. | Prop | Type | Default | Description | | -------- | ------------------------ | ------- | -------------------------------------------- | | `data` | `CandlestickDataPoint[]` | - | Array of candlestick data points to display. | | `config` | `ChartConfig` | `{}` | Configuration object for chart appearance. | | `style` | `ViewStyle` | - | Additional styles to apply to the chart. | ### CandlestickDataPoint | Prop | Type | Description | | ------- | -------- | ----------------------------------- | | `date` | `string` | The date/time label for the candle. | | `open` | `number` | The opening price for the period. | | `high` | `number` | The highest price for the period. | | `low` | `number` | The lowest price for the period. | | `close` | `number` | The closing price for the period. | ### ChartConfig | Prop | Type | Default | Description | | ------------ | --------- | ------- | ------------------------------------------------- | | `width` | `number` | - | Fixed width of the chart (auto-sizes if omitted). | | `height` | `number` | `200` | Height of the chart. | | `padding` | `number` | `20` | Padding around the chart. | | `showGrid` | `boolean` | `true` | Whether to show grid lines. | | `showLabels` | `boolean` | `true` | Whether to show date labels. | | `animated` | `boolean` | `true` | Whether to animate the chart on load. | | `duration` | `number` | `800` | Animation duration in milliseconds. | ## Features - **Smooth Animations**: Built-in animations using React Native Reanimated - **Financial Data**: Specialized for OHLC (Open, High, Low, Close) data - **Color Coding**: Automatic bullish (green) and bearish (red) candle colors - **Responsive Design**: Automatically adapts to container width - **Customizable Grid**: Optional grid lines for better readability - **Smart Spacing**: Automatic candle width and spacing calculations - **Theme Integration**: Uses theme colors for consistent styling ## Accessibility The CandlestickChart component is built with accessibility in mind: - The chart's outer container exposes accessibilityRole="image" with a synthesized summary label (candle count and value range) ## Performance The component is optimized for performance: - Uses React Native Reanimated for smooth 60fps animations - Efficient SVG rendering with minimal re-renders - Gesture handling optimized for touch interactions - Automatic cleanup of animation values ## Financial Data Visualization The candlestick chart is specifically designed for financial data: - **Bullish Candles**: Green candles when close > open (price increased) - **Bearish Candles**: Red candles when close \< open (price decreased) - **Wicks**: Show the full price range (high and low) for each period - **Body**: Shows the open and close prices for each period - **Automatic Scaling**: Prices are automatically scaled to fit the chart area <!-- ---------------------------------------------------------------------- --> # Chart Container > A container component for wrapping charts with title, description, and consistent styling. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/charts/chart-container - Markdown: https://ui.ahmedbna.com/docs/charts/chart-container.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/chart-container.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/chart-container.json - Install: `npx bna-ui add chart-container` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `text`, `view` - Preview recording: https://demo.ahmedbna.com/0345-chart-container-demo.MOV --- **Example:** A basic chart container with title and description ```tsx // components/demo/charts/chart-container/chart-container-demo.tsx import { ChartContainer } from '@/components/charts/chart-container'; import { LineChart } from '@/components/charts/line-chart'; import React from 'react'; const sampleData = [ { x: 'Jan', y: 100, label: 'January' }, { x: 'Feb', y: 120, label: 'February' }, { x: 'Mar', y: 90, label: 'March' }, { x: 'Apr', y: 140, label: 'April' }, { x: 'May', y: 110, label: 'May' }, ]; export function ChartContainerDemo() { return ( <ChartContainer title='Monthly Revenue' description='Revenue data for the last 6 months' > <LineChart data={sampleData} config={{ height: 200, animated: true, showGrid: true, showLabels: true, }} /> </ChartContainer> ); } ``` ## Installation ### CLI ```bash npx bna-ui add chart-container ``` ### Manual **1.** Copy and paste the following code into your project. ```tsx // components/charts/chart-container.tsx import { Text } from '@/components/ui/text'; import { View } from '@/components/ui/view'; import { useColor } from '@/hooks/useColor'; import { BORDER_RADIUS } from '@/theme/globals'; import { ViewStyle } from 'react-native'; type Props = { title?: string; description?: string; children: React.ReactNode; style?: ViewStyle; }; export const ChartContainer = ({ title, description, children, style, }: Props) => { const cardColor = useColor('card'); return ( <View style={[ { backgroundColor: cardColor, borderRadius: BORDER_RADIUS, padding: 16, width: '100%', // Full container width }, style, ]} > {title && ( <Text variant='subtitle' style={{ marginBottom: 4 }}> {title} </Text> )} {description && ( <Text variant='caption' style={{ marginBottom: 16 }}> {description} </Text> )} {children} </View> ); }; ``` **2.** Update the import paths to match your project setup. ## Usage ```tsx import { ChartContainer } from '@/components/charts/chart-container'; ``` ```tsx <ChartContainer title='Monthly Revenue' description='Revenue data for the last 6 months' > {/* Your chart component goes here */} </ChartContainer> ``` ## Examples #### Default **Example:** A basic chart container with title and description ```tsx // components/demo/charts/chart-container/chart-container-demo.tsx import { ChartContainer } from '@/components/charts/chart-container'; import { LineChart } from '@/components/charts/line-chart'; import React from 'react'; const sampleData = [ { x: 'Jan', y: 100, label: 'January' }, { x: 'Feb', y: 120, label: 'February' }, { x: 'Mar', y: 90, label: 'March' }, { x: 'Apr', y: 140, label: 'April' }, { x: 'May', y: 110, label: 'May' }, ]; export function ChartContainerDemo() { return ( <ChartContainer title='Monthly Revenue' description='Revenue data for the last 6 months' > <LineChart data={sampleData} config={{ height: 200, animated: true, showGrid: true, showLabels: true, }} /> </ChartContainer> ); } ``` #### Custom Styling **Example:** Chart container with custom styling ```tsx // components/demo/charts/chart-container/chart-container-styled.tsx import { ChartContainer } from '@/components/charts/chart-container'; import { LineChart } from '@/components/charts/line-chart'; import { useColor } from '@/hooks/useColor'; import React from 'react'; const sampleData = [ { x: 'Q1', y: 65, label: 'Quarter 1' }, { x: 'Q2', y: 80, label: 'Quarter 2' }, { x: 'Q3', y: 75, label: 'Quarter 3' }, { x: 'Q4', y: 95, label: 'Quarter 4' }, ]; export function ChartContainerStyled() { const backgroundColor = useColor('indigo'); return ( <ChartContainer title='Quarterly Growth' description='Performance metrics by quarter' style={{ borderWidth: 2, borderColor: '#e2e8f0', shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 4, elevation: 3, backgroundColor: backgroundColor, }} > <LineChart data={sampleData} config={{ height: 180, animated: true, showGrid: true, showLabels: true, }} /> </ChartContainer> ); } ``` ## API Reference ### ChartContainer A container component that provides consistent styling and layout for charts. | Prop | Type | Description | | ------------- | ----------- | ----------------------------------------------- | | `title` | `string` | The title displayed above the chart. | | `description` | `string` | The description text displayed below the title. | | `children` | `ReactNode` | The chart component to be wrapped. | | `style` | `ViewStyle` | Additional styles to apply to the container. | ## Accessibility The ChartContainer component is built with accessibility in mind: - Uses semantic structure for screen readers - Proper heading hierarchy with title and description - Consistent spacing and layout - Supports dynamic text sizing <!-- ---------------------------------------------------------------------- --> # Column Chart > A customizable horizontal bar chart component with smooth animations and flexible styling. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/charts/column-chart - Markdown: https://ui.ahmedbna.com/docs/charts/column-chart.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/column-chart.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/column-chart.json - Install: `npx bna-ui add column-chart` - npm dependencies: `react-native-reanimated`, `react-native-svg`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals` - Preview recording: https://demo.ahmedbna.com/0347-column-chart-demo.MOV --- **Example:** A horizontal bar chart with smooth animations ```tsx // components/demo/charts/column-chart/column-chart-demo.tsx import { ColumnChart } from '@/components/charts/column-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const sampleData = [ { label: 'Sales', value: 120 }, { label: 'Marketing', value: 98 }, { label: 'Support', value: 86 }, { label: 'Development', value: 140 }, { label: 'Design', value: 75 }, { label: 'HR', value: 65 }, ]; export function ColumnChartDemo() { return ( <ChartContainer title='Department Performance' description='Quarterly performance metrics by department' > <ColumnChart data={sampleData} config={{ height: 300, showLabels: true, animated: true, duration: 1000, }} /> </ChartContainer> ); } ``` ## Installation ### CLI ```bash npx bna-ui add column-chart ``` ### Manual **1.** Install the required dependencies. ```bash npm install react-native-svg react-native-reanimated react-native-worklets ``` **2.** Copy and paste the following code into your project. ```tsx // components/charts/column-chart.tsx import { useColor } from '@/hooks/useColor'; import React, { useEffect, useState } from 'react'; import { LayoutChangeEvent, View, ViewStyle } from 'react-native'; import Animated, { SharedValue, useAnimatedProps, useSharedValue, withTiming, } from 'react-native-reanimated'; import Svg, { G, Rect, Text as SvgText } from 'react-native-svg'; // Animated SVG Components const AnimatedRect = Animated.createAnimatedComponent(Rect); type AnimatedColumnProps = { x: number; y: number; barHeight: number; barWidth: number; fill: string; animationProgress: SharedValue<number>; }; // Per-item hook must live in its own mounted subcomponent, not in the // parent's .map() body — calling useAnimatedProps per loop iteration // violates Rules of Hooks the moment data.length changes. const AnimatedColumn = React.memo( ({ x, y, barHeight, barWidth, fill, animationProgress, }: AnimatedColumnProps) => { const barAnimatedProps = useAnimatedProps(() => ({ width: animationProgress.value * barWidth, })); return ( <AnimatedRect x={x} y={y} height={barHeight} fill={fill} rx={4} animatedProps={barAnimatedProps} /> ); } ); interface ChartConfig { width?: number; height?: number; padding?: number; showLabels?: boolean; animated?: boolean; duration?: number; } interface ChartDataPoint { label: string; value: number; color?: string; } type Props = { data: ChartDataPoint[]; config?: ChartConfig; style?: ViewStyle; }; export const ColumnChart = ({ data, config = {}, style }: Props) => { const [containerWidth, setContainerWidth] = useState(300); const { height = 200, padding = 20, showLabels = true, animated = true, duration = 800, } = config; const chartWidth = containerWidth || config.width || 300; const primaryColor = useColor('primary'); const mutedColor = useColor('mutedForeground'); const animationProgress = useSharedValue(0); const handleLayout = (event: LayoutChangeEvent) => { const { width: measuredWidth } = event.nativeEvent.layout; if (measuredWidth > 0) { setContainerWidth(measuredWidth); } }; useEffect(() => { if (animated) { animationProgress.value = withTiming(1, { duration }); } else { animationProgress.value = 1; } }, [data, animated, duration]); if (!data.length) return null; const maxValue = Math.max(...data.map((d) => d.value)); if (maxValue === 0) return null; const innerChartWidth = chartWidth - padding * 2; const chartHeight = height - padding * 2; const barHeight = (chartHeight / data.length) * 0.8; const barSpacing = (chartHeight / data.length) * 0.2; return ( <View style={[{ width: '100%', height }, style]} onLayout={handleLayout} accessibilityRole='image' accessibilityLabel={`Column chart with ${data.length} bars, maximum value ${Math.round(maxValue)}`} > <Svg width={chartWidth} height={height}> {data.map((item, index) => { const barWidth = (item.value / maxValue) * innerChartWidth; const x = padding; const y = padding + index * (barHeight + barSpacing) + barSpacing / 2; return ( <G key={`bar-${index}`}> <AnimatedColumn x={x} y={y} barHeight={barHeight} barWidth={barWidth} fill={item.color || primaryColor} animationProgress={animationProgress} /> {showLabels && ( <> <SvgText x={padding - 10} y={y + barHeight / 2} textAnchor='end' fontSize={12} fill={mutedColor} alignmentBaseline='middle' > {item.label} </SvgText> <SvgText x={x + barWidth + 10} y={y + barHeight / 2} textAnchor='start' fontSize={11} fill={mutedColor} fontWeight='600' alignmentBaseline='middle' > {item.value} </SvgText> </> )} </G> ); })} </Svg> </View> ); }; ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { ColumnChart } from '@/components/charts/column-chart'; ``` ```tsx const data = [ { label: 'Sales', value: 120 }, { label: 'Marketing', value: 98 }, { label: 'Support', value: 86 }, { label: 'Development', value: 140 }, ]; <ColumnChart data={data} config={{ height: 200, showLabels: true, animated: true, }} />; ``` ## Examples #### Basic Column Chart **Example:** A horizontal bar chart with smooth animations ```tsx // components/demo/charts/column-chart/column-chart-demo.tsx import { ColumnChart } from '@/components/charts/column-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const sampleData = [ { label: 'Sales', value: 120 }, { label: 'Marketing', value: 98 }, { label: 'Support', value: 86 }, { label: 'Development', value: 140 }, { label: 'Design', value: 75 }, { label: 'HR', value: 65 }, ]; export function ColumnChartDemo() { return ( <ChartContainer title='Department Performance' description='Quarterly performance metrics by department' > <ColumnChart data={sampleData} config={{ height: 300, showLabels: true, animated: true, duration: 1000, }} /> </ChartContainer> ); } ``` #### Sample Column Chart **Example:** A sample column chart ```tsx // components/demo/charts/column-chart/column-chart-sample.tsx import { ColumnChart } from '@/components/charts/column-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import { useColor } from '@/hooks/useColor'; import React, { useState } from 'react'; import { Pressable, Text, View } from 'react-native'; const sampleData = [ { label: 'Q1 2024', value: 850 }, { label: 'Q2 2024', value: 920 }, { label: 'Q3 2024', value: 1100 }, { label: 'Q4 2024', value: 1250 }, ]; export function ColumnChartSample() { const [selectedIndex, setSelectedIndex] = useState<number | null>(null); const primaryColor = useColor('primary'); const mutedColor = useColor('muted'); const enhancedData = sampleData.map((item, index) => ({ ...item, color: selectedIndex === index ? primaryColor : mutedColor, })); return ( <ChartContainer title='Interactive Revenue Chart' description='Tap on quarters to highlight them' > <ColumnChart data={enhancedData} config={{ height: 250, showLabels: true, animated: true, duration: 600, }} /> <View style={{ marginTop: 16, flexDirection: 'row', flexWrap: 'wrap', gap: 8, }} > {sampleData.map((item, index) => ( <Pressable key={index} onPress={() => setSelectedIndex(selectedIndex === index ? null : index) } style={{ padding: 8, backgroundColor: selectedIndex === index ? primaryColor : mutedColor, borderRadius: 6, minWidth: 60, alignItems: 'center', }} > <Text style={{ color: selectedIndex === index ? 'white' : 'gray', fontSize: 12, fontWeight: '500', }} > {item.label} </Text> </Pressable> ))} </View> </ChartContainer> ); } ``` #### Styled Column Chart **Example:** A customized column chart with custom colors and styling ```tsx // components/demo/charts/column-chart/column-chart-styled.tsx import { ColumnChart } from '@/components/charts/column-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const sampleData = [ { label: 'Mobile', value: 45, color: '#3b82f6' }, { label: 'Desktop', value: 35, color: '#10b981' }, { label: 'Tablet', value: 15, color: '#f59e0b' }, { label: 'Smart TV', value: 8, color: '#ef4444' }, { label: 'Wearable', value: 3, color: '#8b5cf6' }, ]; export function ColumnChartStyled() { return ( <ChartContainer title='Device Usage Statistics' description='User engagement by device type with custom colors' > <ColumnChart data={sampleData} config={{ height: 280, padding: 24, showLabels: true, animated: true, duration: 1200, }} style={{ backgroundColor: 'rgba(0, 0, 0, 0.02)', borderRadius: 12, padding: 16, }} /> </ChartContainer> ); } ``` #### Large Column Chart **Example:** A column chart with large dataset ```tsx // components/demo/charts/column-chart/column-chart-large.tsx import { ColumnChart } from '@/components/charts/column-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const largeSampleData = [ { label: 'E-commerce', value: 2840, color: '#ff0066' }, { label: 'AI', value: 2440, color: '#ff9900' }, { label: 'Healthcare', value: 2150, color: '#00e6cc' }, { label: 'Education', value: 1920, color: '#0099ff' }, { label: 'Finance', value: 1780, color: '#ffcc00' }, { label: 'Real Estate', value: 1650, color: '#9933ff' }, { label: 'Travel', value: 1420, color: '#ff0080' }, { label: 'Food & Dining', value: 1380, color: '#00cc66' }, { label: 'Entertainment', value: 1250, color: '#ff6600' }, { label: 'Sports', value: 1180, color: '#3399ff' }, { label: 'Technology', value: 1050, color: '#cc66ff' }, { label: 'Fashion', value: 980, color: '#ff3030' }, { label: 'Automotive', value: 875, color: '#ff9900' }, { label: 'Home & Garden', value: 720, color: '#0066ff' }, { label: 'Beauty', value: 650, color: '#ff3366' }, { label: 'Pets', value: 580, color: '#00ffcc' }, ]; export function ColumnChartLarge() { return ( <ChartContainer title='Industry Revenue Analysis' description='Annual revenue by industry sector (in millions)' > <ColumnChart data={largeSampleData} config={{ height: 500, padding: 20, showLabels: true, animated: true, duration: 4000, }} /> </ChartContainer> ); } ``` ## API Reference ### ColumnChart A customizable horizontal bar chart component with smooth animations and flexible styling. Perfect for displaying categorical data with emphasis on comparison between categories. | Prop | Type | Default | Description | | -------- | ------------------ | ------- | --------------------------------------------- | | `data` | `ChartDataPoint[]` | - | Array of data points to display on the chart. | | `config` | `ChartConfig` | `{}` | Configuration object for chart appearance. | | `style` | `ViewStyle` | - | Additional styles to apply to the chart. | ### ChartDataPoint | Prop | Type | Description | | ------- | -------- | ---------------------------------- | | `label` | `string` | The label for the data point. | | `value` | `number` | The value for the data point. | | `color` | `string` | Optional custom color for the bar. | ### ChartConfig | Prop | Type | Default | Description | | ------------ | --------- | ------- | ------------------------------------------------- | | `width` | `number` | - | Fixed width of the chart (auto-sizes if omitted). | | `height` | `number` | `200` | Height of the chart. | | `padding` | `number` | `20` | Padding around the chart. | | `showLabels` | `boolean` | `true` | Whether to show labels for bars. | | `animated` | `boolean` | `true` | Whether to animate the chart on load. | | `duration` | `number` | `800` | Animation duration in milliseconds. | ## Features - **Horizontal Layout**: Displays bars horizontally for better label readability - **Smooth Animations**: Built-in animations using React Native Reanimated - **Responsive Design**: Automatically adapts to container width - **Custom Colors**: Support for individual bar colors - **Label Display**: Shows both category labels and values - **Theme Integration**: Uses theme colors for consistent styling - **Rounded Corners**: Aesthetic rounded bar corners ## Use Cases Column charts are particularly effective for: - **Category Comparison**: Comparing values across different categories - **Performance Metrics**: Displaying KPIs, scores, or ratings - **Survey Results**: Showing response distributions - **Budget Allocation**: Visualizing spending across departments - **Progress Tracking**: Displaying completion rates or achievements ## Design Considerations The horizontal layout of the ColumnChart makes it ideal for: - **Long Category Names**: Labels are displayed to the left of bars, allowing for longer text - **Small Screens**: Horizontal bars work better on mobile devices - **Multiple Categories**: Easier to scan through many categories vertically - **Value Comparison**: Horizontal alignment makes it easier to compare bar lengths ## Accessibility The ColumnChart component includes several accessibility features: - Semantic SVG structure for screen readers - Proper contrast ratios for visual elements - Text labels for both categories and values - Supports dynamic text sizing - Keyboard navigation support ## Performance The component is optimized for performance: - Uses React Native Reanimated for smooth 60fps animations - Efficient SVG rendering with minimal re-renders - Automatic cleanup of animation values - Responsive layout calculations ## Styling The component integrates with your theme system: - Uses `primary` color from theme for default bar color - Uses `mutedForeground` color for labels and text - Supports custom colors per data point - Rounded corners with consistent border radius ## Animation The chart features smooth entry animations: - Bars animate from 0 width to full width - Configurable animation duration - Can be disabled for instant rendering - Uses React Native Reanimated for optimal performance <!-- ---------------------------------------------------------------------- --> # Doughnut Chart > A customizable doughnut chart component with smooth animations, interactive legends, and flexible styling. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/charts/doughnut-chart - Markdown: https://ui.ahmedbna.com/docs/charts/doughnut-chart.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/doughnut-chart.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/doughnut-chart.json - Install: `npx bna-ui add doughnut-chart` - npm dependencies: `react-native-reanimated`, `react-native-svg`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `text` - Preview recording: https://demo.ahmedbna.com/0351-doughnut-chart-demo.MOV --- **Example:** A doughnut chart with smooth animations and percentage labels ```tsx // components/demo/charts/doughnut-chart/doughnut-chart-demo.tsx import { DoughnutChart } from '@/components/charts/doughnut-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const sampleData = [ { label: 'Sales', value: 120 }, { label: 'Marketing', value: 98 }, { label: 'Support', value: 86 }, { label: 'Development', value: 140 }, { label: 'Design', value: 75 }, { label: 'HR', value: 65 }, ]; export function DoughnutChartDemo() { return ( <ChartContainer title='Department Performance' description='Quarterly performance metrics by department' > <DoughnutChart data={sampleData} config={{ height: 300, showLabels: true, animated: true, duration: 1000, innerRadius: 0.6, }} /> </ChartContainer> ); } ``` ## Installation ### CLI ```bash npx bna-ui add doughnut-chart ``` ### Manual **1.** Install the required dependencies. ```bash npm install react-native-svg react-native-reanimated react-native-worklets ``` **2.** Copy and paste the following code into your project. ```tsx // components/charts/doughnut-chart.tsx import { Text } from '@/components/ui/text'; import { useColor } from '@/hooks/useColor'; import React, { useEffect, useState } from 'react'; import { LayoutChangeEvent, View, ViewStyle } from 'react-native'; import Animated, { SharedValue, useAnimatedProps, useSharedValue, withTiming, } from 'react-native-reanimated'; import Svg, { Circle, G, Path, Text as SvgText } from 'react-native-svg'; // Animated SVG Components const AnimatedPath = Animated.createAnimatedComponent(Path); const AnimatedCircle = Animated.createAnimatedComponent(Circle); type AnimatedSliceProps = { d: string; fill: string; animationProgress: SharedValue<number>; // A single 100%-share slice makes the arc's start/end points coincide, // which SVG's arc command can't draw — render a stroked ring instead. fullRing?: { cx: number; cy: number; meanRadius: number; strokeWidth: number; }; }; // Per-item hook must live in its own mounted subcomponent, not in the // parent's .map() body — calling useAnimatedProps per loop iteration // violates Rules of Hooks the moment data.length changes. const AnimatedSlice = React.memo( ({ d, fill, animationProgress, fullRing }: AnimatedSliceProps) => { const sliceAnimatedProps = useAnimatedProps(() => ({ opacity: animationProgress.value, })); if (fullRing) { return ( <AnimatedCircle cx={fullRing.cx} cy={fullRing.cy} r={fullRing.meanRadius} fill='none' stroke={fill} strokeWidth={fullRing.strokeWidth} animatedProps={sliceAnimatedProps} /> ); } return ( <AnimatedPath d={d} fill={fill} animatedProps={sliceAnimatedProps} /> ); } ); interface ChartConfig { width?: number; height?: number; showLabels?: boolean; animated?: boolean; duration?: number; innerRadius?: number; } interface ChartDataPoint { label: string; value: number; color?: string; } type Props = { data: ChartDataPoint[]; config?: ChartConfig; style?: ViewStyle; }; export const DoughnutChart = ({ data, config = {}, style }: Props) => { const [containerWidth, setContainerWidth] = useState(300); const { height = 200, showLabels = true, animated = true, duration = 1000, innerRadius = 0.5, // Default inner radius as ratio of outer radius } = config; const chartWidth = containerWidth || config.width || 300; const primaryColor = useColor('primary'); const animationProgress = useSharedValue(0); const handleLayout = (event: LayoutChangeEvent) => { const { width: measuredWidth } = event.nativeEvent.layout; if (measuredWidth > 0) { setContainerWidth(measuredWidth); } }; useEffect(() => { if (animated) { animationProgress.value = withTiming(1, { duration }); } else { animationProgress.value = 1; } }, [data, animated, duration]); if (!data.length) return null; const total = data.reduce((sum, item) => sum + item.value, 0); if (total === 0) return null; const outerRadius = Math.min(chartWidth, height) / 2 - 20; const clampedInnerRadius = Math.max(0, Math.min(0.95, innerRadius)); const innerRadiusValue = outerRadius * clampedInnerRadius; const centerX = chartWidth / 2; const centerY = height / 2; let currentAngle = -Math.PI / 2; const colors = [ primaryColor, useColor('blue'), useColor('green'), useColor('orange'), useColor('purple'), useColor('pink'), ]; return ( <View style={[{ width: '100%' }, style]} onLayout={handleLayout} accessibilityRole='image' accessibilityLabel={`Doughnut chart with ${data.length} slices, total ${Math.round(total)}`} > <Svg width={chartWidth} height={height}> {data.map((item, index) => { const sliceAngle = (item.value / total) * 2 * Math.PI; const startAngle = currentAngle; const endAngle = currentAngle + sliceAngle; const largeArcFlag = sliceAngle > Math.PI ? 1 : 0; // Outer arc points const x1 = centerX + outerRadius * Math.cos(startAngle); const y1 = centerY + outerRadius * Math.sin(startAngle); const x2 = centerX + outerRadius * Math.cos(endAngle); const y2 = centerY + outerRadius * Math.sin(endAngle); // Inner arc points const x3 = centerX + innerRadiusValue * Math.cos(endAngle); const y3 = centerY + innerRadiusValue * Math.sin(endAngle); const x4 = centerX + innerRadiusValue * Math.cos(startAngle); const y4 = centerY + innerRadiusValue * Math.sin(startAngle); const pathData = [ `M ${x1} ${y1}`, `A ${outerRadius} ${outerRadius} 0 ${largeArcFlag} 1 ${x2} ${y2}`, `L ${x3} ${y3}`, `A ${innerRadiusValue} ${innerRadiusValue} 0 ${largeArcFlag} 0 ${x4} ${y4}`, 'Z', ].join(' '); // Label position const labelAngle = startAngle + sliceAngle / 2; const labelRadius = (outerRadius + innerRadiusValue) / 2; const labelX = centerX + labelRadius * Math.cos(labelAngle); const labelY = centerY + labelRadius * Math.sin(labelAngle); currentAngle = endAngle; // A single slice spanning the full circle (only one item, or every // other item has a value of 0) has coincident arc start/end points. const isFullCircle = sliceAngle >= 2 * Math.PI - 1e-6; return ( <G key={`slice-${index}`}> <AnimatedSlice d={pathData} fill={item.color || colors[index % colors.length]} animationProgress={animationProgress} fullRing={ isFullCircle ? { cx: centerX, cy: centerY, meanRadius: (outerRadius + innerRadiusValue) / 2, strokeWidth: outerRadius - innerRadiusValue, } : undefined } /> {showLabels && ( <SvgText x={labelX} y={labelY} textAnchor='middle' fontSize={12} fill='#FFFFFF' fontWeight='600' > {Math.round((item.value / total) * 100)}% </SvgText> )} </G> ); })} </Svg> {/* Legend */} <View style={{ marginTop: 10 }}> {data.map((item, index) => ( <View key={`legend-${index}`} style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 5, }} > <View style={{ width: 12, height: 12, borderRadius: 6, backgroundColor: item.color || colors[index % colors.length], marginRight: 8, }} /> <Text variant='caption'> {item.label}: {item.value} </Text> </View> ))} </View> </View> ); }; ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { DoughnutChart } from '@/components/charts/doughnut-chart'; ``` ```tsx const data = [ { label: 'Sales', value: 120 }, { label: 'Marketing', value: 98 }, { label: 'Support', value: 86 }, { label: 'Development', value: 140 }, ]; <DoughnutChart data={data} config={{ height: 300, showLabels: true, animated: true, innerRadius: 0.6, }} />; ``` ## Examples #### Basic Doughnut Chart **Example:** A doughnut chart with smooth animations and percentage labels ```tsx // components/demo/charts/doughnut-chart/doughnut-chart-demo.tsx import { DoughnutChart } from '@/components/charts/doughnut-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const sampleData = [ { label: 'Sales', value: 120 }, { label: 'Marketing', value: 98 }, { label: 'Support', value: 86 }, { label: 'Development', value: 140 }, { label: 'Design', value: 75 }, { label: 'HR', value: 65 }, ]; export function DoughnutChartDemo() { return ( <ChartContainer title='Department Performance' description='Quarterly performance metrics by department' > <DoughnutChart data={sampleData} config={{ height: 300, showLabels: true, animated: true, duration: 1000, innerRadius: 0.6, }} /> </ChartContainer> ); } ``` #### Sample Doughnut Chart **Example:** A sample doughnut chart with custom theme colors ```tsx // components/demo/charts/doughnut-chart/doughnut-chart-sample.tsx import { DoughnutChart } from '@/components/charts/doughnut-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import { useColor } from '@/hooks/useColor'; import React from 'react'; const sampleData = [ { label: 'Revenue', value: 45000 }, { label: 'Expenses', value: 32000 }, { label: 'Profit', value: 13000 }, ]; export function DoughnutChartSample() { const primaryColor = useColor('primary'); const greenColor = useColor('green'); const orangeColor = useColor('orange'); const dataWithColors = [ { ...sampleData[0], color: primaryColor }, { ...sampleData[1], color: orangeColor }, { ...sampleData[2], color: greenColor }, ]; return ( <ChartContainer title='Financial Overview' description='Q4 2024 financial breakdown' > <DoughnutChart data={dataWithColors} config={{ height: 250, showLabels: true, animated: true, duration: 1500, innerRadius: 0.5, }} /> </ChartContainer> ); } ``` #### Styled Doughnut Chart **Example:** A customized doughnut chart with custom colors and styling ```tsx // components/demo/charts/doughnut-chart/doughnut-chart-styled.tsx import { DoughnutChart } from '@/components/charts/doughnut-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const customData = [ { label: 'Mobile', value: 65, color: '#FF6B6B' }, { label: 'Desktop', value: 25, color: '#4ECDC4' }, { label: 'Tablet', value: 8, color: '#45B7D1' }, { label: 'Other', value: 2, color: '#96CEB4' }, ]; export function DoughnutChartStyled() { return ( <ChartContainer title='Device Usage' description='Traffic distribution by device type' > <DoughnutChart data={customData} config={{ height: 280, showLabels: true, animated: true, duration: 800, innerRadius: 0.7, }} style={{ backgroundColor: '#f8f9fa', borderRadius: 12, padding: 16, }} /> </ChartContainer> ); } ``` #### Large Doughnut Chart **Example:** A doughnut chart with large dataset and legend-only labels ```tsx // components/demo/charts/doughnut-chart/doughnut-chart-large.tsx import { DoughnutChart } from '@/components/charts/doughnut-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const largeDataset = [ { label: 'E-commerce', value: 285, color: '#FF6B6B' }, { label: 'Social Media', value: 245, color: '#4ECDC4' }, { label: 'Search Engine', value: 198, color: '#45B7D1' }, { label: 'Email Marketing', value: 156, color: '#96CEB4' }, { label: 'Direct Traffic', value: 134, color: '#FFEAA7' }, { label: 'Referral', value: 89, color: '#DDA0DD' }, { label: 'Display Ads', value: 67, color: '#98D8C8' }, { label: 'Video Ads', value: 45, color: '#F7DC6F' }, { label: 'Affiliate', value: 23, color: '#BB8FCE' }, { label: 'Other', value: 18, color: '#AED6F1' }, ]; export function DoughnutChartLarge() { return ( <ChartContainer title='Traffic Sources' description='Website traffic breakdown by source (last 30 days)' > <DoughnutChart data={largeDataset} config={{ height: 320, showLabels: false, // Disable labels for large datasets animated: true, duration: 1200, innerRadius: 0.4, }} /> </ChartContainer> ); } ``` ## API Reference ### DoughnutChart A customizable doughnut chart component with smooth animations, interactive legends, and flexible styling. Perfect for displaying proportional data with emphasis on part-to-whole relationships. | Prop | Type | Default | Description | | -------- | ------------------ | ------- | --------------------------------------------- | | `data` | `ChartDataPoint[]` | - | Array of data points to display on the chart. | | `config` | `ChartConfig` | `{}` | Configuration object for chart appearance. | | `style` | `ViewStyle` | - | Additional styles to apply to the chart. | ### ChartDataPoint | Prop | Type | Description | | ------- | -------- | ------------------------------------ | | `label` | `string` | The label for the data point. | | `value` | `number` | The value for the data point. | | `color` | `string` | Optional custom color for the slice. | ### ChartConfig | Prop | Type | Default | Description | | ------------- | --------- | ------- | ------------------------------------------------- | | `width` | `number` | - | Fixed width of the chart (auto-sizes if omitted). | | `height` | `number` | `200` | Height of the chart. | | `showLabels` | `boolean` | `true` | Whether to show percentage labels on slices. | | `animated` | `boolean` | `true` | Whether to animate the chart on load. | | `duration` | `number` | `1000` | Animation duration in milliseconds. | | `innerRadius` | `number` | `0.5` | Inner radius as a ratio of outer radius (0-1). | ## Features - **Circular Layout**: Displays data as slices of a circle for intuitive proportion visualization - **Smooth Animations**: Built-in animations using React Native Reanimated - **Responsive Design**: Automatically adapts to container width - **Interactive Legend**: Built-in legend with color indicators and values - **Custom Colors**: Support for individual slice colors - **Percentage Labels**: Shows percentage values on chart slices - **Theme Integration**: Uses theme colors for consistent styling - **Configurable Inner Radius**: Adjustable doughnut thickness ## Use Cases Doughnut charts are particularly effective for: - **Part-to-Whole Relationships**: Showing how individual parts contribute to a total - **Market Share Analysis**: Displaying market distribution across competitors - **Budget Breakdown**: Visualizing spending allocation across categories - **Survey Results**: Showing response distributions with clear proportions - **Resource Allocation**: Displaying time, money, or resource distribution - **Performance Metrics**: Showing completion rates or achievement percentages ## Design Considerations The circular layout of the DoughnutChart makes it ideal for: - **Proportional Data**: Perfect for showing percentages and ratios - **Limited Categories**: Works best with 3-8 categories for clarity - **Space Efficiency**: Compact design that fits well in dashboards - **Visual Impact**: Immediately conveys relative sizes and proportions ## Accessibility The DoughnutChart component includes several accessibility features: - Semantic SVG structure for screen readers - Proper contrast ratios for visual elements - Text labels with percentage values - Interactive legend for detailed information - Color-blind friendly default palette - Keyboard navigation support ## Performance The component is optimized for performance: - Uses React Native Reanimated for smooth 60fps animations - Efficient SVG rendering with minimal re-renders - Automatic cleanup of animation values - Responsive layout calculations - Optimized path calculations for smooth arcs ## Styling The component integrates with your theme system: - Uses theme colors (`primary`, `blue`, `green`, etc.) for default slice colors - Uses `mutedForeground` color for labels and legend text - Supports custom colors per data point - Automatic color cycling for consistent appearance - Customizable container styling ## Animation The chart features smooth entry animations: - Slices animate with fade-in effect - Configurable animation duration - Can be disabled for instant rendering - Uses React Native Reanimated for optimal performance - Smooth transitions maintain visual continuity ## Legend The built-in legend provides: - Color-coded indicators for each slice - Category labels with actual values - Automatic layout below the chart - Consistent styling with theme colors - Compact design that doesn't overwhelm the chart <!-- ---------------------------------------------------------------------- --> # Heatmap Chart > A customizable heatmap chart component with smooth animations and flexible color scaling for visualizing matrix data. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/charts/heatmap-chart - Markdown: https://ui.ahmedbna.com/docs/charts/heatmap-chart.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/heatmap-chart.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/heatmap-chart.json - Install: `npx bna-ui add heatmap-chart` - npm dependencies: `react-native-reanimated`, `react-native-svg`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals` - Preview recording: https://demo.ahmedbna.com/0355-heatmap-chart-demo.MOV --- **Example:** A heatmap chart with smooth animations and color scaling ```tsx // components/demo/charts/heatmap-chart/heatmap-chart-demo.tsx import { HeatmapChart } from '@/components/charts/heatmap-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const sampleData = [ { row: 'Mon', col: 'Morning', value: 45 }, { row: 'Mon', col: 'Afternoon', value: 62 }, { row: 'Mon', col: 'Evening', value: 38 }, { row: 'Tue', col: 'Morning', value: 52 }, { row: 'Tue', col: 'Afternoon', value: 71 }, { row: 'Tue', col: 'Evening', value: 43 }, { row: 'Wed', col: 'Morning', value: 39 }, { row: 'Wed', col: 'Afternoon', value: 85 }, { row: 'Wed', col: 'Evening', value: 57 }, { row: 'Thu', col: 'Morning', value: 68 }, { row: 'Thu', col: 'Afternoon', value: 92 }, { row: 'Thu', col: 'Evening', value: 61 }, { row: 'Fri', col: 'Morning', value: 73 }, { row: 'Fri', col: 'Afternoon', value: 88 }, { row: 'Fri', col: 'Evening', value: 79 }, ]; export function HeatmapChartDemo() { return ( <ChartContainer title='Weekly Activity Heatmap' description='Activity levels throughout the week by time of day' > <HeatmapChart data={sampleData} config={{ height: 300, showLabels: true, animated: true, duration: 1000, colorScale: ['#e0f2fe', '#0369a1', '#1e3a8a'], }} /> </ChartContainer> ); } ``` ## Installation ### CLI ```bash npx bna-ui add heatmap-chart ``` ### Manual **1.** Install the required dependencies. ```bash npm install react-native-svg react-native-reanimated react-native-worklets ``` **2.** Copy and paste the following code into your project. ```tsx // components/charts/heatmap-chart.tsx import { useColor } from '@/hooks/useColor'; import React, { useEffect, useMemo, useState } from 'react'; import { LayoutChangeEvent, View, ViewStyle } from 'react-native'; import Animated, { SharedValue, useAnimatedProps, useSharedValue, withDelay, withTiming, } from 'react-native-reanimated'; import Svg, { G, Rect, Text as SvgText } from 'react-native-svg'; // Animated SVG Components const AnimatedRect = Animated.createAnimatedComponent(Rect); type AnimatedCellProps = { x: number; y: number; width: number; height: number; fill: string; delay: number; animationProgress: SharedValue<number>; }; // Per-item hook must live in its own mounted subcomponent, not in the // parent's nested rows×cols .map() body — calling useAnimatedProps per // loop iteration violates Rules of Hooks the moment data changes, and this // is the worst multiplier in the chart set (one mount per row×col cell). const AnimatedCell = React.memo( ({ x, y, width, height, fill, delay, animationProgress, }: AnimatedCellProps) => { const cellAnimatedProps = useAnimatedProps(() => ({ opacity: withDelay( delay, withTiming(animationProgress.value, { duration: 300 }) ), })); return ( <AnimatedRect x={x} y={y} width={width} height={height} fill={fill} rx={4} animatedProps={cellAnimatedProps} /> ); } ); // Utility functions const interpolateColor = ( color1: string, color2: string, factor: number ): string => { // Simple color interpolation between two hex colors const hex1 = color1.replace('#', ''); const hex2 = color2.replace('#', ''); const r1 = parseInt(hex1.substr(0, 2), 16); const g1 = parseInt(hex1.substr(2, 2), 16); const b1 = parseInt(hex1.substr(4, 2), 16); const r2 = parseInt(hex2.substr(0, 2), 16); const g2 = parseInt(hex2.substr(2, 2), 16); const b2 = parseInt(hex2.substr(4, 2), 16); const r = Math.round(r1 + (r2 - r1) * factor); const g = Math.round(g1 + (g2 - g1) * factor); const b = Math.round(b1 + (b2 - b1) * factor); return `#${r.toString(16).padStart(2, '0')}${g .toString(16) .padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; }; const getHeatmapColor = ( value: number, minValue: number, maxValue: number, colorScale: string[] ): string => { if (maxValue === minValue) return colorScale[0]; const normalizedValue = (value - minValue) / (maxValue - minValue); const segmentSize = 1 / (colorScale.length - 1); const segmentIndex = Math.floor(normalizedValue / segmentSize); const segmentProgress = (normalizedValue % segmentSize) / segmentSize; if (segmentIndex >= colorScale.length - 1) { return colorScale[colorScale.length - 1]; } return interpolateColor( colorScale[segmentIndex], colorScale[segmentIndex + 1], segmentProgress ); }; interface ChartConfig { width?: number; height?: number; padding?: number; showLabels?: boolean; animated?: boolean; duration?: number; colorScale?: string[]; } interface HeatmapDataPoint { row: string | number; col: string | number; value: number; label?: string; } type Props = { data: HeatmapDataPoint[]; config?: ChartConfig; style?: ViewStyle; }; export const HeatmapChart = ({ data, config = {}, style }: Props) => { const [containerWidth, setContainerWidth] = useState(300); const { height = 200, padding = 20, showLabels = true, animated = true, duration = 1000, colorScale = ['#e0f2fe', '#0369a1', '#1e3a8a'], // Light blue to dark blue } = config; // Use measured width or fallback to config width or default const chartWidth = containerWidth || config.width || 300; const mutedColor = useColor('mutedForeground'); const textColor = useColor('foreground'); const animationProgress = useSharedValue(0); const handleLayout = (event: LayoutChangeEvent) => { const { width: measuredWidth } = event.nativeEvent.layout; if (measuredWidth > 0) { setContainerWidth(measuredWidth); } }; useEffect(() => { if (animated) { animationProgress.value = withTiming(1, { duration }); } else { animationProgress.value = 1; } }, [data, animated, duration]); // The grid rebuild is O(rows×cols) — the worst-case multiplier in the // chart set — so it's memoized rather than recomputed on every render // regardless of whether the inputs changed. const layout = useMemo(() => { const uniqueRows = [...new Set(data.map((d) => d.row))].sort(); const uniqueCols = [...new Set(data.map((d) => d.col))].sort(); const numRows = uniqueRows.length; const numCols = uniqueCols.length; const values = data.map((d) => d.value); const minValue = Math.min(...values); const maxValue = Math.max(...values); const innerChartWidth = chartWidth - padding * 2; const chartHeight = height - padding * 2; const cellSpacing = 2; const cellWidth = (innerChartWidth - (numCols - 1) * cellSpacing) / numCols; const cellHeight = (chartHeight - (numRows - 1) * cellSpacing) / numRows; const dataMap = new Map<string, HeatmapDataPoint>(); data.forEach((point) => { dataMap.set(`${point.row}-${point.col}`, point); }); const cells = uniqueRows.flatMap((row, rowIndex) => uniqueCols.map((col, colIndex) => { const point = dataMap.get(`${row}-${col}`); const value = point?.value || 0; return { key: `${row}-${col}`, row, col, value, hasPoint: !!point, x: padding + colIndex * (cellWidth + cellSpacing), y: padding + rowIndex * (cellHeight + cellSpacing), color: getHeatmapColor(value, minValue, maxValue, colorScale), delay: (rowIndex * numCols + colIndex) * 50, }; }) ); return { uniqueRows, uniqueCols, numRows, numCols, minValue, maxValue, cellWidth, cellHeight, cellSpacing, cells, }; }, [data, chartWidth, height, padding, colorScale]); if (!data.length) return null; const { uniqueRows, uniqueCols, numRows, numCols, minValue, maxValue, cellWidth, cellHeight, cellSpacing, cells, } = layout; return ( <View style={[{ width: '100%', height }, style]} onLayout={handleLayout} accessibilityRole='image' accessibilityLabel={`Heatmap with ${numRows} rows and ${numCols} columns, values from ${Math.round(minValue)} to ${Math.round(maxValue)}`} > <Svg width={chartWidth} height={height}> {cells.map((cell) => ( <G key={`cell-${cell.key}`}> <AnimatedCell x={cell.x} y={cell.y} width={cellWidth} height={cellHeight} fill={cell.color} delay={cell.delay} animationProgress={animationProgress} /> {showLabels && cellWidth > 30 && cellHeight > 20 && ( <SvgText x={cell.x + cellWidth / 2} y={cell.y + cellHeight / 2 + 4} textAnchor='middle' fontSize={Math.min(10, cellWidth / 4)} fill={ cell.value > (minValue + maxValue) / 2 ? '#ffffff' : textColor } fontWeight='500' > {cell.hasPoint ? cell.value.toString() : ''} </SvgText> )} </G> ))} {/* Row labels */} {showLabels && ( <G> {uniqueRows.map((row, rowIndex) => ( <SvgText key={`row-label-${row}`} x={padding - 8} y={ padding + rowIndex * (cellHeight + cellSpacing) + cellHeight / 2 + 4 } textAnchor='end' fontSize={12} fill={mutedColor} > {row} </SvgText> ))} </G> )} {/* Column labels */} {showLabels && ( <G> {uniqueCols.map((col, colIndex) => ( <SvgText key={`col-label-${col}`} x={ padding + colIndex * (cellWidth + cellSpacing) + cellWidth / 2 } y={height - 5} textAnchor='middle' fontSize={12} fill={mutedColor} > {col} </SvgText> ))} </G> )} </Svg> </View> ); }; ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { HeatmapChart } from '@/components/charts/heatmap-chart'; ``` ```tsx const data = [ { row: 'Mon', col: 'Morning', value: 45 }, { row: 'Mon', col: 'Afternoon', value: 62 }, { row: 'Mon', col: 'Evening', value: 38 }, { row: 'Tue', col: 'Morning', value: 52 }, { row: 'Tue', col: 'Afternoon', value: 71 }, { row: 'Tue', col: 'Evening', value: 43 }, ]; <HeatmapChart data={data} config={{ height: 300, showLabels: true, animated: true, colorScale: ['#e0f2fe', '#0369a1', '#1e3a8a'], }} />; ``` ## Examples #### Basic Heatmap Chart **Example:** A heatmap chart with smooth animations and color scaling ```tsx // components/demo/charts/heatmap-chart/heatmap-chart-demo.tsx import { HeatmapChart } from '@/components/charts/heatmap-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const sampleData = [ { row: 'Mon', col: 'Morning', value: 45 }, { row: 'Mon', col: 'Afternoon', value: 62 }, { row: 'Mon', col: 'Evening', value: 38 }, { row: 'Tue', col: 'Morning', value: 52 }, { row: 'Tue', col: 'Afternoon', value: 71 }, { row: 'Tue', col: 'Evening', value: 43 }, { row: 'Wed', col: 'Morning', value: 39 }, { row: 'Wed', col: 'Afternoon', value: 85 }, { row: 'Wed', col: 'Evening', value: 57 }, { row: 'Thu', col: 'Morning', value: 68 }, { row: 'Thu', col: 'Afternoon', value: 92 }, { row: 'Thu', col: 'Evening', value: 61 }, { row: 'Fri', col: 'Morning', value: 73 }, { row: 'Fri', col: 'Afternoon', value: 88 }, { row: 'Fri', col: 'Evening', value: 79 }, ]; export function HeatmapChartDemo() { return ( <ChartContainer title='Weekly Activity Heatmap' description='Activity levels throughout the week by time of day' > <HeatmapChart data={sampleData} config={{ height: 300, showLabels: true, animated: true, duration: 1000, colorScale: ['#e0f2fe', '#0369a1', '#1e3a8a'], }} /> </ChartContainer> ); } ``` #### Sample Heatmap Chart **Example:** A sample heatmap chart with different data ```tsx // components/demo/charts/heatmap-chart/heatmap-chart-sample.tsx import { HeatmapChart } from '@/components/charts/heatmap-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const sampleData = [ { row: 'Q1', col: 'Sales', value: 85 }, { row: 'Q1', col: 'Marketing', value: 72 }, { row: 'Q1', col: 'Support', value: 90 }, { row: 'Q1', col: 'Development', value: 78 }, { row: 'Q2', col: 'Sales', value: 92 }, { row: 'Q2', col: 'Marketing', value: 65 }, { row: 'Q2', col: 'Support', value: 88 }, { row: 'Q2', col: 'Development', value: 95 }, { row: 'Q3', col: 'Sales', value: 78 }, { row: 'Q3', col: 'Marketing', value: 83 }, { row: 'Q3', col: 'Support', value: 91 }, { row: 'Q3', col: 'Development', value: 87 }, { row: 'Q4', col: 'Sales', value: 96 }, { row: 'Q4', col: 'Marketing', value: 89 }, { row: 'Q4', col: 'Support', value: 94 }, { row: 'Q4', col: 'Development', value: 92 }, ]; export function HeatmapChartSample() { return ( <ChartContainer title='Quarterly Performance Matrix' description='Performance scores by department and quarter' > <HeatmapChart data={sampleData} config={{ height: 280, showLabels: true, animated: true, duration: 800, colorScale: ['#fef3c7', '#f59e0b', '#d97706'], }} /> </ChartContainer> ); } ``` #### Styled Heatmap Chart **Example:** A customized heatmap chart with custom colors and styling ```tsx // components/demo/charts/heatmap-chart/heatmap-chart-styled.tsx import { HeatmapChart } from '@/components/charts/heatmap-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import { useColor } from '@/hooks/useColor'; import React from 'react'; const sampleData = [ { row: 'Low', col: 'Low', value: 10 }, { row: 'Low', col: 'Medium', value: 25 }, { row: 'Low', col: 'High', value: 40 }, { row: 'Medium', col: 'Low', value: 30 }, { row: 'Medium', col: 'Medium', value: 55 }, { row: 'Medium', col: 'High', value: 70 }, { row: 'High', col: 'Low', value: 50 }, { row: 'High', col: 'Medium', value: 75 }, { row: 'High', col: 'High', value: 95 }, ]; export function HeatmapChartStyled() { const isDark = useColor('background') === '#000000'; const colorScale = isDark ? ['#0f172a', '#1e293b', '#334155', '#64748b', '#94a3b8'] : ['#f8fafc', '#e2e8f0', '#cbd5e1', '#94a3b8', '#64748b']; return ( <ChartContainer title='Risk Assessment Matrix' description='Risk levels across different probability and impact combinations' > <HeatmapChart data={sampleData} config={{ height: 250, showLabels: true, animated: true, duration: 1200, colorScale, padding: 30, }} /> </ChartContainer> ); } ``` #### Large Heatmap Chart **Example:** A heatmap chart with large dataset ```tsx // components/demo/charts/heatmap-chart/heatmap-chart-large.tsx import { HeatmapChart } from '@/components/charts/heatmap-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; // Generate large dataset const generateLargeDataset = () => { const data = []; const months = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', ]; const hours = Array.from({ length: 24 }, (_, i) => i.toString().padStart(2, '0') ); for (const month of months) { for (const hour of hours) { // Generate realistic activity data (higher during work hours) const isWorkHour = parseInt(hour) >= 8 && parseInt(hour) <= 18; const baseValue = isWorkHour ? 40 : 10; const randomVariation = Math.random() * 30; const value = Math.round(baseValue + randomVariation); data.push({ row: month, col: `${hour}:00`, value, }); } } return data; }; const largeDataset = generateLargeDataset(); export function HeatmapChartLarge() { return ( <ChartContainer title='Annual Activity Heatmap' description='User activity patterns throughout the year by hour of day' > <HeatmapChart data={largeDataset} config={{ height: 400, showLabels: false, // Disabled for large datasets animated: true, duration: 1500, colorScale: ['#ecfdf5', '#10b981', '#065f46'], padding: 40, }} /> </ChartContainer> ); } ``` ## API Reference ### HeatmapChart A customizable heatmap chart component with smooth animations and flexible color scaling. Perfect for visualizing matrix data, correlation matrices, activity patterns, and any two-dimensional data with intensity values. | Prop | Type | Default | Description | | -------- | -------------------- | ------- | --------------------------------------------- | | `data` | `HeatmapDataPoint[]` | - | Array of data points to display on the chart. | | `config` | `ChartConfig` | `{}` | Configuration object for chart appearance. | | `style` | `ViewStyle` | - | Additional styles to apply to the chart. | ### HeatmapDataPoint | Prop | Type | Description | | ------- | ------------------ | ----------------------------------------- | | `row` | `string \| number` | The row identifier for the data point. | | `col` | `string \| number` | The column identifier for the data point. | | `value` | `number` | The intensity value for the data point. | | `label` | `string` | Optional custom label for the data point. | ### ChartConfig | Prop | Type | Default | Description | | ------------ | ---------- | ----------------------------------- | ------------------------------------------------- | | `width` | `number` | - | Fixed width of the chart (auto-sizes if omitted). | | `height` | `number` | `200` | Height of the chart. | | `padding` | `number` | `20` | Padding around the chart. | | `showLabels` | `boolean` | `true` | Whether to show labels for cells and axes. | | `animated` | `boolean` | `true` | Whether to animate the chart on load. | | `duration` | `number` | `1000` | Animation duration in milliseconds. | | `colorScale` | `string[]` | `['#e0f2fe', '#0369a1', '#1e3a8a']` | Array of hex colors for the gradient scale. | ## Features - **Matrix Visualization**: Displays data in a grid format with color-coded intensity - **Smooth Animations**: Built-in staggered animations using React Native Reanimated - **Responsive Design**: Automatically adapts to container width - **Custom Color Scales**: Support for multi-color gradients - **Value Display**: Shows actual values within cells when space permits - **Axis Labels**: Displays row and column labels for context - **Theme Integration**: Uses theme colors for consistent styling - **Rounded Corners**: Aesthetic rounded cell corners ## Use Cases Heatmap charts are particularly effective for: - **Activity Patterns**: Visualizing user activity across time periods - **Correlation Analysis**: Displaying correlation matrices between variables - **Performance Metrics**: Showing performance across different dimensions - **Geographic Data**: Visualizing data intensity across regions - **Quality Metrics**: Displaying quality scores across products/services - **Risk Assessment**: Showing risk levels across different categories - **Resource Utilization**: Visualizing usage patterns across time and resources ## Design Considerations The HeatmapChart component is designed for: - **Data Density**: Efficiently displays large amounts of matrix data - **Pattern Recognition**: Color coding helps identify patterns and outliers - **Comparative Analysis**: Easy to compare values across rows and columns - **Scalability**: Handles varying grid sizes automatically - **Accessibility**: High contrast colors and value labels for clarity ## Color Scaling The heatmap uses intelligent color interpolation: - **Gradient Generation**: Smoothly interpolates between multiple colors - **Value Normalization**: Automatically scales colors based on data range - **Custom Palettes**: Supports any number of colors in the scale - **Contrast Optimization**: Automatically adjusts text color for readability ## Animation The chart features sophisticated entry animations: - **Staggered Reveal**: Cells animate in sequence for visual appeal - **Opacity Transitions**: Smooth fade-in effects - **Configurable Timing**: Adjustable animation duration - **Performance Optimized**: Uses React Native Reanimated for 60fps animations ## Accessibility The HeatmapChart component includes several accessibility features: - **Semantic Structure**: Proper SVG structure for screen readers - **Value Labels**: Numeric values displayed within cells - **High Contrast**: Automatic text color adjustment for readability - **Descriptive Labels**: Row and column labels provide context - **Keyboard Navigation**: Supports focus management ## Performance The component is optimized for performance: - **Efficient Rendering**: Uses SVG for crisp graphics at any scale - **Animation Optimization**: Leverages React Native Reanimated - **Memory Management**: Efficient data structures and cleanup - **Responsive Layout**: Minimal re-renders on size changes ## Styling The component integrates with your theme system: - **Theme Colors**: Uses `mutedForeground` and `foreground` from theme - **Custom Color Scales**: Override default colors with custom palettes - **Consistent Spacing**: Maintains consistent cell spacing and padding - **Rounded Aesthetics**: Configurable border radius for cells ## Data Requirements The heatmap requires properly structured data: - **Complete Coverage**: All row/column combinations should be provided - **Numeric Values**: Values must be numeric for proper color scaling - **Consistent Types**: Row and column identifiers should be consistent - **Sorted Data**: Data is automatically sorted but pre-sorting improves performance ## Best Practices For optimal results with HeatmapChart: - **Meaningful Labels**: Use descriptive row and column labels - **Appropriate Color Scales**: Choose colors that represent your data semantically - **Reasonable Grid Size**: Consider screen size when determining grid dimensions - **Value Ranges**: Ensure your data has sufficient range for meaningful color variation - **Loading States**: Consider showing loading indicators for large datasets <!-- ---------------------------------------------------------------------- --> # Line Chart > A customizable line chart component with animations, interactions, and gradient fills. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/charts/line-chart - Markdown: https://ui.ahmedbna.com/docs/charts/line-chart.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/line-chart.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/line-chart.json - Install: `npx bna-ui add line-chart` - npm dependencies: `react-native-gesture-handler`, `react-native-reanimated`, `react-native-svg`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals` - Preview recording: https://demo.ahmedbna.com/0359-line-chart-demo.MOV --- **Example:** A basic line chart with smooth animations and grid lines ```tsx // components/demo/charts/line-chart/line-chart-demo.tsx import { ChartContainer } from '@/components/charts/chart-container'; import { LineChart } from '@/components/charts/line-chart'; import React from 'react'; const sampleData = [ { x: 1, y: 10, label: 'Jan' }, { x: 2, y: 25, label: 'Feb' }, { x: 3, y: 15, label: 'Mar' }, { x: 4, y: 40, label: 'Apr' }, { x: 5, y: 30, label: 'May' }, { x: 6, y: 55, label: 'Jun' }, { x: 7, y: 45, label: 'Jul' }, ]; export function LineChartDemo() { return ( <ChartContainer title='Revenue Trend' description='Monthly revenue growth over time' > <LineChart data={sampleData} config={{ height: 220, showGrid: true, showLabels: true, animated: true, duration: 1500, interactive: true, }} /> </ChartContainer> ); } ``` ## Installation ### CLI ```bash npx bna-ui add line-chart ``` ### Manual **1.** Install the required dependencies. ```bash npm install react-native-svg react-native-reanimated react-native-worklets react-native-gesture-handler ``` **2.** Copy and paste the following code into your project. ```tsx // components/charts/line-chart.tsx import { useColor } from '@/hooks/useColor'; import React, { useEffect, useState } from 'react'; import { LayoutChangeEvent, View, ViewStyle } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { runOnJS, SharedValue, useAnimatedProps, useSharedValue, withDelay, withSpring, withTiming, } from 'react-native-reanimated'; import Svg, { Circle, Defs, G, Line, LinearGradient, Path, Stop, Text as SvgText, } from 'react-native-svg'; interface ChartConfig { width?: number; height?: number; padding?: number; showGrid?: boolean; showLabels?: boolean; animated?: boolean; duration?: number; gradient?: boolean; interactive?: boolean; showYLabels?: boolean; yLabelCount?: number; yAxisWidth?: number; } export type ChartDataPoint = { x: string | number; y: number; label?: string; }; // Utility functions const createPath = (points: { x: number; y: number }[]): string => { if (points.length === 0) return ''; let path = `M${points[0].x},${points[0].y}`; for (let i = 1; i < points.length; i++) { const prevPoint = points[i - 1]; const currentPoint = points[i]; // Create smooth curves using quadratic bezier const cpx = (prevPoint.x + currentPoint.x) / 2; const cpy = prevPoint.y; path += ` Q${cpx},${cpy} ${currentPoint.x},${currentPoint.y}`; } return path; }; const createAreaPath = ( points: { x: number; y: number }[], height: number ): string => { if (points.length === 0) return ''; let path = createPath(points); const lastPoint = points[points.length - 1]; const firstPoint = points[0]; path += ` L${lastPoint.x},${height} L${firstPoint.x},${height} Z`; return path; }; // Helper function to format numbers for display const formatNumber = (num: number): string => { if (num >= 1000000) { return (num / 1000000).toFixed(1) + 'M'; } else if (num >= 1000) { return (num / 1000).toFixed(1) + 'K'; } return num.toFixed(0); }; // Animated SVG Components const AnimatedPath = Animated.createAnimatedComponent(Path); const AnimatedCircle = Animated.createAnimatedComponent(Circle); type AnimatedPointProps = { x: number; y: number; color: string; index: number; animationProgress: SharedValue<number>; }; // Per-item hook must live in its own mounted subcomponent, not in the // parent's .map() body — calling useAnimatedProps/useAnimatedStyle per // loop iteration violates Rules of Hooks the moment data.length changes. const AnimatedPoint = React.memo( ({ x, y, color, index, animationProgress }: AnimatedPointProps) => { // Animate the radius (not a style `scale` transform, which would pivot // around the SVG origin rather than the circle's own center) for a // staggered spring pop-in per point. const pointAnimatedProps = useAnimatedProps(() => ({ opacity: animationProgress.value, r: withDelay(index * 50, withSpring(animationProgress.value * 4)), })); return ( <AnimatedCircle cx={x} cy={y} fill={color} animatedProps={pointAnimatedProps} /> ); } ); type Props = { data: ChartDataPoint[]; config?: ChartConfig; style?: ViewStyle; }; export const LineChart = ({ data, config = {}, style }: Props) => { const [containerWidth, setContainerWidth] = useState(300); const { height = 200, padding = 20, showGrid = true, showLabels = true, animated = true, duration = 1000, gradient = false, interactive = false, showYLabels = true, yLabelCount = 5, yAxisWidth = 20, } = config; // Use measured width or fallback to config width or default const chartWidth = containerWidth || config.width || 300; const primaryColor = useColor('primary'); const mutedColor = useColor('mutedForeground'); const animationProgress = useSharedValue(0); const [activePointIndex, setActivePointIndex] = useState<number | null>(null); const handleLayout = (event: LayoutChangeEvent) => { const { width: measuredWidth } = event.nativeEvent.layout; if (measuredWidth > 0) { setContainerWidth(measuredWidth); } }; useEffect(() => { if (animated) { animationProgress.value = withTiming(1, { duration }); } else { animationProgress.value = 1; } }, [data, animated, duration]); if (!data.length) return null; const maxValue = Math.max(...data.map((d) => d.y)); const minValue = Math.min(...data.map((d) => d.y)); const valueRange = maxValue - minValue || 1; // Adjust padding to account for y-axis labels const leftPadding = showYLabels ? padding + yAxisWidth : padding; const innerChartWidth = chartWidth - leftPadding - padding; const chartHeight = height - padding * 2; // Convert data to screen coordinates. A single-point dataset would divide // by zero (data.length - 1 === 0) — center it instead of producing NaN. const points = data.map((point, index) => ({ x: leftPadding + (data.length > 1 ? index / (data.length - 1) : 0.5) * innerChartWidth, y: padding + ((maxValue - point.y) / valueRange) * chartHeight, })); const pathData = createPath(points); const areaPathData = gradient ? createAreaPath(points, height - padding) : ''; // Generate y-axis labels const yAxisLabels = []; if (showYLabels) { for (let i = 0; i < yLabelCount; i++) { const ratio = i / (yLabelCount - 1); const value = maxValue - ratio * valueRange; const y = padding + ratio * chartHeight; yAxisLabels.push({ value, y }); } } // Fixed animated props for SVG components const areaAnimatedProps = useAnimatedProps(() => ({ strokeDasharray: animated ? `${animationProgress.value * 1000} 1000` : undefined, })); const lineAnimatedProps = useAnimatedProps(() => ({ strokeDasharray: animated ? `${animationProgress.value * 1000} 1000` : undefined, })); const findNearestPointIndex = (x: number): number => { let nearest = 0; let minDistance = Math.abs(points[0].x - x); for (let i = 1; i < points.length; i++) { const distance = Math.abs(points[i].x - x); if (distance < minDistance) { minDistance = distance; nearest = i; } } return nearest; }; // Pan gesture using new Gesture API. Disabled (not just no-op'd) when // !interactive so it doesn't compete with a parent ScrollView's own pan // recognizer for charts that never use it. const panGesture = Gesture.Pan() .enabled(interactive) .onStart((event) => { runOnJS(setActivePointIndex)(findNearestPointIndex(event.x)); }) .onUpdate((event) => { runOnJS(setActivePointIndex)(findNearestPointIndex(event.x)); }) .onEnd(() => { runOnJS(setActivePointIndex)(null); }); const chartAccessibilityLabel = `Line chart with ${data.length} data points, ranging from ${formatNumber(minValue)} to ${formatNumber(maxValue)}`; return ( <View style={[{ width: '100%', height }, style]} onLayout={handleLayout} accessibilityRole='image' accessibilityLabel={chartAccessibilityLabel} > <GestureDetector gesture={panGesture}> <Animated.View> <Svg width={chartWidth} height={height}> <Defs> {gradient && ( <LinearGradient id='gradient' x1='0%' y1='0%' x2='0%' y2='100%'> <Stop offset='0%' stopColor={primaryColor} stopOpacity='0.3' /> <Stop offset='100%' stopColor={primaryColor} stopOpacity='0.05' /> </LinearGradient> )} </Defs> {/* Y-axis labels */} {showYLabels && ( <G> {yAxisLabels.map((label, index) => ( <SvgText key={`y-label-${index}`} x={leftPadding - 10} y={label.y + 4} textAnchor='end' fontSize={10} fill={mutedColor} > {formatNumber(label.value)} </SvgText> ))} </G> )} {/* Grid lines */} {showGrid && ( <G> {/* Horizontal grid lines */} {yAxisLabels.map((label, index) => ( <Line key={`grid-h-${index}`} x1={leftPadding} y1={label.y} x2={chartWidth - padding} y2={label.y} stroke={mutedColor} strokeWidth={0.5} opacity={0.3} /> ))} {/* Vertical grid lines */} {points.map((point, index) => ( <Line key={`grid-v-${index}`} x1={point.x} y1={padding} x2={point.x} y2={height - padding} stroke={mutedColor} strokeWidth={0.5} opacity={0.2} /> ))} </G> )} {/* Area fill */} {gradient && ( <AnimatedPath d={areaPathData} fill='url(#gradient)' animatedProps={areaAnimatedProps} /> )} {/* Line path */} <AnimatedPath d={pathData} stroke={primaryColor} strokeWidth={2} fill='none' strokeLinecap='round' strokeLinejoin='round' animatedProps={lineAnimatedProps} /> {/* Data points */} {points.map((point, index) => ( <AnimatedPoint key={`point-${index}`} x={point.x} y={point.y} color={primaryColor} index={index} animationProgress={animationProgress} /> ))} {/* X-axis labels */} {showLabels && ( <G> {data.map((point, index) => ( <SvgText key={`x-label-${index}`} x={points[index].x} y={height - 5} textAnchor='middle' fontSize={10} fill={mutedColor} > {point.label || point.x.toString()} </SvgText> ))} </G> )} {/* Interactive tooltip */} {interactive && activePointIndex !== null && ( <G> <Line x1={points[activePointIndex].x} y1={padding} x2={points[activePointIndex].x} y2={height - padding} stroke={mutedColor} strokeWidth={1} strokeDasharray='4 4' opacity={0.5} /> <Circle cx={points[activePointIndex].x} cy={points[activePointIndex].y} r={6} fill={primaryColor} stroke='white' strokeWidth={2} /> <SvgText x={points[activePointIndex].x} y={Math.max(12, points[activePointIndex].y - 12)} textAnchor='middle' fontSize={11} fontWeight='700' fill={mutedColor} > {formatNumber(data[activePointIndex].y)} </SvgText> </G> )} </Svg> </Animated.View> </GestureDetector> </View> ); }; ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { LineChart } from '@/components/charts/line-chart'; ``` ```tsx const data = [ { x: 'Jan', y: 100, label: 'January' }, { x: 'Feb', y: 120, label: 'February' }, { x: 'Mar', y: 90, label: 'March' }, { x: 'Apr', y: 140, label: 'April' }, ]; <LineChart data={data} config={{ height: 200, showGrid: true, showLabels: true, animated: true, }} />; ``` ## Examples #### Basic Line Chart **Example:** A basic line chart with smooth animations and grid lines ```tsx // components/demo/charts/line-chart/line-chart-demo.tsx import { ChartContainer } from '@/components/charts/chart-container'; import { LineChart } from '@/components/charts/line-chart'; import React from 'react'; const sampleData = [ { x: 1, y: 10, label: 'Jan' }, { x: 2, y: 25, label: 'Feb' }, { x: 3, y: 15, label: 'Mar' }, { x: 4, y: 40, label: 'Apr' }, { x: 5, y: 30, label: 'May' }, { x: 6, y: 55, label: 'Jun' }, { x: 7, y: 45, label: 'Jul' }, ]; export function LineChartDemo() { return ( <ChartContainer title='Revenue Trend' description='Monthly revenue growth over time' > <LineChart data={sampleData} config={{ height: 220, showGrid: true, showLabels: true, animated: true, duration: 1500, interactive: true, }} /> </ChartContainer> ); } ``` #### Interactive Line Chart **Example:** An interactive line chart with touch gestures ```tsx // components/demo/charts/line-chart/line-chart-interactive.tsx import { ChartContainer } from '@/components/charts/chart-container'; import { LineChart } from '@/components/charts/line-chart'; import React from 'react'; const sampleData = [ { x: 'Q1', y: 45, label: 'Q1 2024' }, { x: 'Q2', y: 67, label: 'Q2 2024' }, { x: 'Q3', y: 52, label: 'Q3 2024' }, { x: 'Q4', y: 89, label: 'Q4 2024' }, { x: 'Q1', y: 95, label: 'Q1 2025' }, { x: 'Q2', y: 110, label: 'Q2 2025' }, ]; export function LineChartInteractive() { return ( <ChartContainer title='Interactive Revenue Chart' description='Touch and drag to explore data points' > <LineChart data={sampleData} config={{ height: 240, showGrid: true, showLabels: true, animated: true, duration: 1200, interactive: true, showYLabels: true, yLabelCount: 6, }} /> </ChartContainer> ); } ``` #### Styled Line Chart **Example:** A customized line chart with custom styling ```tsx // components/demo/charts/line-chart/line-chart-styled.tsx import { ChartContainer } from '@/components/charts/chart-container'; import { LineChart } from '@/components/charts/line-chart'; import { useColor } from '@/hooks/useColor'; import React from 'react'; const sampleData = [ { x: 'Mon', y: 23, label: 'Monday' }, { x: 'Tue', y: 45, label: 'Tuesday' }, { x: 'Wed', y: 67, label: 'Wednesday' }, { x: 'Thu', y: 34, label: 'Thursday' }, { x: 'Fri', y: 89, label: 'Friday' }, { x: 'Sat', y: 56, label: 'Saturday' }, { x: 'Sun', y: 78, label: 'Sunday' }, ]; export function LineChartStyled() { const borderColor = useColor('border'); const backgroundColor = useColor('card'); return ( <ChartContainer title='Weekly Performance' description='Styled chart with custom appearance' style={{ borderWidth: 1, borderColor: borderColor, backgroundColor: backgroundColor, borderRadius: 12, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 8, elevation: 4, }} > <LineChart data={sampleData} config={{ height: 200, showGrid: true, showLabels: true, animated: true, duration: 2000, showYLabels: true, yLabelCount: 4, padding: 24, }} /> </ChartContainer> ); } ``` #### Minimal Line Chart **Example:** A minimal line chart ```tsx // components/demo/charts/line-chart/line-chart-minimal.tsx import { LineChart } from '@/components/charts/line-chart'; import React from 'react'; const sampleData = [ { x: 1, y: 20 }, { x: 2, y: 45 }, { x: 3, y: 28 }, { x: 4, y: 67 }, { x: 5, y: 89 }, { x: 6, y: 34 }, ]; export function LineChartMinimal() { return ( <LineChart data={sampleData} config={{ height: 160, showGrid: false, showLabels: false, animated: true, duration: 800, showYLabels: false, padding: 16, }} /> ); } ``` ## API Reference ### LineChart A customizable line chart component with smooth animations and interactive features. | Prop | Type | Default | Description | | -------- | ------------------ | ------- | --------------------------------------------- | | `data` | `ChartDataPoint[]` | - | Array of data points to display on the chart. | | `config` | `ChartConfig` | `{}` | Configuration object for chart appearance. | | `style` | `ViewStyle` | - | Additional styles to apply to the chart. | ### ChartDataPoint | Prop | Type | Description | | ------- | ------------------ | ------------------------------------ | | `x` | `string \| number` | The x-axis value for the data point. | | `y` | `number` | The y-axis value for the data point. | | `label` | `string` | Optional label for the data point. | ### ChartConfig | Prop | Type | Default | Description | | ------------- | --------- | ------- | ------------------------------------------------- | | `width` | `number` | - | Fixed width of the chart (auto-sizes if omitted). | | `height` | `number` | `200` | Height of the chart. | | `padding` | `number` | `20` | Padding around the chart. | | `showGrid` | `boolean` | `true` | Whether to show grid lines. | | `showLabels` | `boolean` | `true` | Whether to show x-axis labels. | | `animated` | `boolean` | `true` | Whether to animate the chart on load. | | `duration` | `number` | `1000` | Animation duration in milliseconds. | | `gradient` | `boolean` | `false` | Whether to show gradient fill under the line. | | `interactive` | `boolean` | `false` | Whether to enable touch interactions. | | `showYLabels` | `boolean` | `true` | Whether to show y-axis labels. | | `yLabelCount` | `number` | `5` | Number of y-axis labels to display. | | `yAxisWidth` | `number` | `20` | Width allocated for y-axis labels. | ## Features - **Smooth Animations**: Built-in animations using React Native Reanimated - **Interactive Touch**: Optional touch gestures for data exploration - **Responsive Design**: Automatically adapts to container width - **Customizable Grid**: Optional grid lines for better readability - **Gradient Fill**: Optional gradient fill under the line - **Curved Lines**: Smooth bezier curves between data points - **Smart Formatting**: Automatic number formatting (K, M suffixes) - **Theme Integration**: Uses theme colors for consistent styling ## Accessibility The LineChart component is built with accessibility in mind: - Semantic SVG structure for screen readers - Proper contrast ratios for visual elements - Touch targets meet minimum size requirements - Supports dynamic text sizing - Keyboard navigation support (when interactive) ## Performance The component is optimized for performance: - Uses React Native Reanimated for smooth 60fps animations - Efficient SVG rendering with minimal re-renders - Gesture handling optimized for touch interactions - Automatic cleanup of animation values <!-- ---------------------------------------------------------------------- --> # Pie Chart > A customizable pie chart component with smooth animations and flexible styling. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/charts/pie-chart - Markdown: https://ui.ahmedbna.com/docs/charts/pie-chart.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/pie-chart.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/pie-chart.json - Install: `npx bna-ui add pie-chart` - npm dependencies: `react-native-reanimated`, `react-native-svg`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `text` - Preview recording: https://demo.ahmedbna.com/0363-pie-chart-demo.MOV --- **Example:** A pie chart with smooth animations ```tsx // components/demo/charts/pie-chart/pie-chart-demo.tsx import { PieChart } from '@/components/charts/pie-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const sampleData = [ { label: 'Sales', value: 120 }, { label: 'Marketing', value: 98 }, { label: 'Support', value: 86 }, { label: 'Development', value: 140 }, { label: 'Design', value: 75 }, { label: 'HR', value: 65 }, ]; export function PieChartDemo() { return ( <ChartContainer title='Department Performance' description='Quarterly performance metrics by department' > <PieChart data={sampleData} config={{ height: 300, showLabels: true, animated: true, duration: 1000, }} /> </ChartContainer> ); } ``` ## Installation ### CLI ```bash npx bna-ui add pie-chart ``` ### Manual **1.** Install the required dependencies. ```bash npm install react-native-svg react-native-reanimated react-native-worklets ``` **2.** Copy and paste the following code into your project. ```tsx // components/charts/pie-chart.tsx // components/charts/pie-chart.tsx import { Text } from '@/components/ui/text'; import { useColor } from '@/hooks/useColor'; import React, { useEffect, useState } from 'react'; import { LayoutChangeEvent, View, ViewStyle } from 'react-native'; import Animated, { SharedValue, useAnimatedProps, useSharedValue, withTiming, } from 'react-native-reanimated'; import Svg, { Circle, G, Path, Text as SvgText } from 'react-native-svg'; // Animated SVG Components const AnimatedPath = Animated.createAnimatedComponent(Path); const AnimatedCircle = Animated.createAnimatedComponent(Circle); type AnimatedSliceProps = { d: string; fill: string; animationProgress: SharedValue<number>; // A single 100%-share slice makes the arc's start/end points coincide, // which SVG's arc command can't draw — render a plain circle instead. fullCircle?: { cx: number; cy: number; r: number }; }; // Per-item hook must live in its own mounted subcomponent, not in the // parent's .map() body — calling useAnimatedProps per loop iteration // violates Rules of Hooks the moment data.length changes. const AnimatedSlice = React.memo( ({ d, fill, animationProgress, fullCircle }: AnimatedSliceProps) => { const sliceAnimatedProps = useAnimatedProps(() => ({ opacity: animationProgress.value, })); if (fullCircle) { return ( <AnimatedCircle cx={fullCircle.cx} cy={fullCircle.cy} r={fullCircle.r} fill={fill} animatedProps={sliceAnimatedProps} /> ); } return ( <AnimatedPath d={d} fill={fill} animatedProps={sliceAnimatedProps} /> ); } ); interface ChartConfig { width?: number; height?: number; showLabels?: boolean; animated?: boolean; duration?: number; } interface ChartDataPoint { label: string; value: number; color?: string; } type Props = { data: ChartDataPoint[]; config?: ChartConfig; style?: ViewStyle; }; export const PieChart = ({ data, config = {}, style }: Props) => { const [containerWidth, setContainerWidth] = useState(300); const { height = 200, showLabels = true, animated = true, duration = 1000, } = config; // Use measured width or fallback to config width or default const chartWidth = containerWidth || config.width || 300; const primaryColor = useColor('primary'); const animationProgress = useSharedValue(0); const handleLayout = (event: LayoutChangeEvent) => { const { width: measuredWidth } = event.nativeEvent.layout; if (measuredWidth > 0) { setContainerWidth(measuredWidth); } }; useEffect(() => { if (animated) { animationProgress.value = withTiming(1, { duration }); } else { animationProgress.value = 1; } }, [data, animated, duration]); if (!data.length) return null; const total = data.reduce((sum, item) => sum + item.value, 0); if (total === 0) return null; const radius = Math.min(chartWidth, height) / 2 - 20; const centerX = chartWidth / 2; const centerY = height / 2; let currentAngle = -Math.PI / 2; // Start from top const colors = [ primaryColor, useColor('blue'), useColor('green'), useColor('orange'), useColor('purple'), useColor('pink'), ]; return ( <View style={[{ width: '100%' }, style]} onLayout={handleLayout} accessibilityRole='image' accessibilityLabel={`Pie chart with ${data.length} slices, total ${Math.round(total)}`} > <Svg width={chartWidth} height={height}> {data.map((item, index) => { const sliceAngle = (item.value / total) * 2 * Math.PI; const startAngle = currentAngle; const endAngle = currentAngle + sliceAngle; const largeArcFlag = sliceAngle > Math.PI ? 1 : 0; const x1 = centerX + radius * Math.cos(startAngle); const y1 = centerY + radius * Math.sin(startAngle); const x2 = centerX + radius * Math.cos(endAngle); const y2 = centerY + radius * Math.sin(endAngle); const pathData = [ `M ${centerX} ${centerY}`, `L ${x1} ${y1}`, `A ${radius} ${radius} 0 ${largeArcFlag} 1 ${x2} ${y2}`, 'Z', ].join(' '); // Label position const labelAngle = startAngle + sliceAngle / 2; const labelRadius = radius * 0.7; const labelX = centerX + labelRadius * Math.cos(labelAngle); const labelY = centerY + labelRadius * Math.sin(labelAngle); currentAngle = endAngle; // A single slice spanning the full circle (only one item, or every // other item has a value of 0) has coincident arc start/end points. const isFullCircle = sliceAngle >= 2 * Math.PI - 1e-6; return ( <G key={`slice-${index}`}> <AnimatedSlice d={pathData} fill={item.color || colors[index % colors.length]} animationProgress={animationProgress} fullCircle={ isFullCircle ? { cx: centerX, cy: centerY, r: radius } : undefined } /> {showLabels && ( <SvgText x={labelX} y={labelY} textAnchor='middle' fontSize={12} fill='#FFFFFF' fontWeight='600' > {Math.round((item.value / total) * 100)}% </SvgText> )} </G> ); })} </Svg> {/* Legend */} <View style={{ marginTop: 10 }}> {data.map((item, index) => ( <View key={`legend-${index}`} style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 5, }} > <View style={{ width: 12, height: 12, borderRadius: 6, backgroundColor: item.color || colors[index % colors.length], marginRight: 8, }} /> <Text variant='caption'> {item.label}: {item.value} </Text> </View> ))} </View> </View> ); }; ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { PieChart } from '@/components/charts/pie-chart'; ``` ```tsx const data = [ { label: 'Sales', value: 120 }, { label: 'Marketing', value: 98 }, { label: 'Support', value: 86 }, { label: 'Development', value: 140 }, ]; <PieChart data={data} config={{ height: 200, showLabels: true, animated: true, }} />; ``` ## Examples #### Basic Pie Chart **Example:** A pie chart with smooth animations ```tsx // components/demo/charts/pie-chart/pie-chart-demo.tsx import { PieChart } from '@/components/charts/pie-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const sampleData = [ { label: 'Sales', value: 120 }, { label: 'Marketing', value: 98 }, { label: 'Support', value: 86 }, { label: 'Development', value: 140 }, { label: 'Design', value: 75 }, { label: 'HR', value: 65 }, ]; export function PieChartDemo() { return ( <ChartContainer title='Department Performance' description='Quarterly performance metrics by department' > <PieChart data={sampleData} config={{ height: 300, showLabels: true, animated: true, duration: 1000, }} /> </ChartContainer> ); } ``` #### Sample Pie Chart **Example:** A sample pie chart ```tsx // components/demo/charts/pie-chart/pie-chart-sample.tsx import { PieChart } from '@/components/charts/pie-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const sampleData = [ { label: 'Mobile', value: 45 }, { label: 'Desktop', value: 35 }, { label: 'Tablet', value: 15 }, { label: 'Other', value: 5 }, ]; export function PieChartSample() { return ( <ChartContainer title='Traffic Sources' description='Website traffic distribution by device type' > <PieChart data={sampleData} config={{ height: 250, showLabels: true, animated: true, duration: 800, }} /> </ChartContainer> ); } ``` #### Styled Pie Chart **Example:** A customized pie chart with custom colors and styling ```tsx // components/demo/charts/pie-chart/pie-chart-styled.tsx import { PieChart } from '@/components/charts/pie-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import { useColor } from '@/hooks/useColor'; import React from 'react'; export function PieChartStyled() { const primaryColor = useColor('primary'); const successColor = useColor('green'); const warningColor = useColor('orange'); const errorColor = useColor('red'); const styledData = [ { label: 'Completed', value: 65, color: successColor }, { label: 'In Progress', value: 20, color: primaryColor }, { label: 'Pending', value: 10, color: warningColor }, { label: 'Failed', value: 5, color: errorColor }, ]; return ( <ChartContainer title='Project Status' description='Current project completion status overview' > <PieChart data={styledData} config={{ height: 280, showLabels: true, animated: true, duration: 1200, }} /> </ChartContainer> ); } ``` #### Large Pie Chart **Example:** A pie chart with large dataset ```tsx // components/demo/charts/pie-chart/pie-chart-large.tsx import { PieChart } from '@/components/charts/pie-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const largeData = [ { label: 'North America', value: 35 }, { label: 'Europe', value: 28 }, { label: 'Asia Pacific', value: 22 }, { label: 'Latin America', value: 8 }, { label: 'Middle East', value: 4 }, { label: 'Africa', value: 3 }, ]; export function PieChartLarge() { return ( <ChartContainer title='Global Revenue Distribution' description='Revenue breakdown by geographical regions' > <PieChart data={largeData} config={{ height: 350, showLabels: true, animated: true, duration: 1500, }} /> </ChartContainer> ); } ``` ## API Reference ### PieChart A customizable pie chart component with smooth animations and flexible styling. Perfect for displaying proportional data with emphasis on parts of a whole. | Prop | Type | Default | Description | | -------- | ------------------ | ------- | --------------------------------------------- | | `data` | `ChartDataPoint[]` | - | Array of data points to display on the chart. | | `config` | `ChartConfig` | `{}` | Configuration object for chart appearance. | | `style` | `ViewStyle` | - | Additional styles to apply to the chart. | ### ChartDataPoint | Prop | Type | Description | | ------- | -------- | ------------------------------------ | | `label` | `string` | The label for the data point. | | `value` | `number` | The value for the data point. | | `color` | `string` | Optional custom color for the slice. | ### ChartConfig | Prop | Type | Default | Description | | ------------ | --------- | ------- | ------------------------------------------------- | | `width` | `number` | - | Fixed width of the chart (auto-sizes if omitted). | | `height` | `number` | `200` | Height of the chart. | | `showLabels` | `boolean` | `true` | Whether to show percentage labels on slices. | | `animated` | `boolean` | `true` | Whether to animate the chart on load. | | `duration` | `number` | `1000` | Animation duration in milliseconds. | ## Features - **Circular Layout**: Displays data as slices of a circle for proportional visualization - **Smooth Animations**: Built-in animations using React Native Reanimated - **Responsive Design**: Automatically adapts to container width - **Custom Colors**: Support for individual slice colors - **Percentage Labels**: Shows percentage values on each slice - **Legend Display**: Displays legend with colors and values below the chart - **Theme Integration**: Uses theme colors for consistent styling - **Auto-sizing**: Automatically calculates optimal size based on container ## Use Cases Pie charts are particularly effective for: - **Market Share**: Displaying market share distribution - **Budget Breakdown**: Showing expense categories as percentages - **Survey Results**: Visualizing response distributions - **Demographics**: Displaying population segments - **Resource Allocation**: Showing how resources are distributed - **Progress Tracking**: Displaying completion vs remaining work ## Design Considerations The circular layout of the PieChart makes it ideal for: - **Proportional Data**: Best for showing parts of a whole - **Limited Categories**: Works best with 2-8 categories - **Percentage Focus**: Emphasizes relative proportions over absolute values - **Quick Comparison**: Easy to see largest and smallest segments ## Accessibility The PieChart component includes several accessibility features: - Semantic SVG structure for screen readers - Proper contrast ratios for visual elements - Text labels for both percentages and categories - Legend with colors and values - Keyboard navigation support ## Performance The component is optimized for performance: - Uses React Native Reanimated for smooth 60fps animations - Efficient SVG rendering with minimal re-renders - Automatic cleanup of animation values - Responsive layout calculations ## Styling The component integrates with your theme system: - Uses theme colors (`primary`, `blue`, `green`, `orange`, `purple`, `pink`) for default slice colors - Uses white text for percentage labels on slices - Supports custom colors per data point - Consistent legend styling with theme colors ## Animation The chart features smooth entry animations: - Slices animate with opacity fade-in effect - Configurable animation duration - Can be disabled for instant rendering - Uses React Native Reanimated for optimal performance ## Mathematical Calculations The component automatically handles: - **Percentage Calculations**: Converts values to percentages - **Angle Calculations**: Converts percentages to slice angles - **Arc Path Generation**: Creates proper SVG arc paths - **Label Positioning**: Calculates optimal label positions within slices - **Legend Generation**: Creates legend items with colors and values ## Best Practices When using pie charts: - **Limit Categories**: Keep to 2-8 categories for clarity - **Order by Size**: Consider ordering slices by size for better readability - **Use Contrasting Colors**: Ensure sufficient contrast between adjacent slices - **Provide Legend**: Always include a legend for color identification - **Consider Alternatives**: For many categories, consider using a bar chart instead <!-- ---------------------------------------------------------------------- --> # Polar Area Chart > A customizable polar area chart component with smooth animations and flexible styling for displaying radial data. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/charts/polar-area-chart - Markdown: https://ui.ahmedbna.com/docs/charts/polar-area-chart.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/polar-area-chart.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/polar-area-chart.json - Install: `npx bna-ui add polar-area-chart` - npm dependencies: `react-native-reanimated`, `react-native-svg`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `text` - Preview recording: https://demo.ahmedbna.com/0367-polar-area-chart-demo.MOV --- **Example:** A polar area chart with smooth animations ```tsx // components/demo/charts/polar-area-chart/polar-area-chart-demo.tsx import { PolarAreaChart } from '@/components/charts/polar-area-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const sampleData = [ { label: 'Sales', value: 120 }, { label: 'Marketing', value: 98 }, { label: 'Support', value: 86 }, { label: 'Development', value: 140 }, { label: 'Design', value: 75 }, { label: 'HR', value: 65 }, ]; export function PolarAreaChartDemo() { return ( <ChartContainer title='Department Performance' description='Quarterly performance metrics by department' > <PolarAreaChart data={sampleData} config={{ height: 300, showLabels: true, animated: true, duration: 1000, }} /> </ChartContainer> ); } ``` ## Installation ### CLI ```bash npx bna-ui add polar-area-chart ``` ### Manual **1.** Install the required dependencies. ```bash npm install react-native-svg react-native-reanimated react-native-worklets ``` **2.** Copy and paste the following code into your project. ```tsx // components/charts/polar-area-chart.tsx import { Text } from '@/components/ui/text'; import { useColor } from '@/hooks/useColor'; import React, { useEffect, useState } from 'react'; import { LayoutChangeEvent, View, ViewStyle } from 'react-native'; import Animated, { SharedValue, useAnimatedProps, useSharedValue, withTiming, } from 'react-native-reanimated'; import Svg, { Circle, G, Path, Text as SvgText } from 'react-native-svg'; // Animated SVG Components const AnimatedPath = Animated.createAnimatedComponent(Path); type AnimatedSliceProps = { d: string; fill: string; animationProgress: SharedValue<number>; }; // Per-item hook must live in its own mounted subcomponent, not in the // parent's .map() body — calling useAnimatedProps per loop iteration // violates Rules of Hooks the moment data.length changes. const AnimatedSlice = React.memo( ({ d, fill, animationProgress }: AnimatedSliceProps) => { const sliceAnimatedProps = useAnimatedProps(() => ({ opacity: animationProgress.value * 0.8, })); return ( <AnimatedPath d={d} fill={fill} stroke='white' strokeWidth={1} animatedProps={sliceAnimatedProps} /> ); } ); interface ChartConfig { width?: number; height?: number; showLabels?: boolean; animated?: boolean; duration?: number; } interface ChartDataPoint { label: string; value: number; color?: string; } type Props = { data: ChartDataPoint[]; config?: ChartConfig; style?: ViewStyle; }; export const PolarAreaChart = ({ data, config = {}, style }: Props) => { const [containerWidth, setContainerWidth] = useState(300); const { height = 200, showLabels = true, animated = true, duration = 1000, } = config; const chartWidth = containerWidth || config.width || 300; const primaryColor = useColor('primary'); const mutedColor = useColor('mutedForeground'); const animationProgress = useSharedValue(0); const handleLayout = (event: LayoutChangeEvent) => { const { width: measuredWidth } = event.nativeEvent.layout; if (measuredWidth > 0) { setContainerWidth(measuredWidth); } }; useEffect(() => { if (animated) { animationProgress.value = withTiming(1, { duration }); } else { animationProgress.value = 1; } }, [data, animated, duration]); if (!data.length) return null; const maxValue = Math.max(...data.map((d) => d.value)); if (maxValue === 0) return null; const centerX = chartWidth / 2; const centerY = height / 2; const maxRadius = Math.min(chartWidth, height) / 2 - 20; const angleStep = (2 * Math.PI) / data.length; const colors = [ primaryColor, useColor('blue'), useColor('green'), useColor('orange'), useColor('purple'), useColor('pink'), ]; return ( <View style={[{ width: '100%', height }, style]} onLayout={handleLayout} accessibilityRole='image' accessibilityLabel={`Polar area chart with ${data.length} segments, maximum value ${Math.round(maxValue)}`} > <Svg width={chartWidth} height={height}> {data.map((item, index) => { const angle = index * angleStep - Math.PI / 2; const nextAngle = (index + 1) * angleStep - Math.PI / 2; const radius = (item.value / maxValue) * maxRadius; const x1 = centerX + radius * Math.cos(angle); const y1 = centerY + radius * Math.sin(angle); const x2 = centerX + radius * Math.cos(nextAngle); const y2 = centerY + radius * Math.sin(nextAngle); const pathData = [ `M ${centerX} ${centerY}`, `L ${x1} ${y1}`, `A ${radius} ${radius} 0 0 1 ${x2} ${y2}`, 'Z', ].join(' '); // Label position const labelAngle = angle + angleStep / 2; const labelRadius = radius * 0.7; const labelX = centerX + labelRadius * Math.cos(labelAngle); const labelY = centerY + labelRadius * Math.sin(labelAngle); return ( <G key={`slice-${index}`}> <AnimatedSlice d={pathData} fill={item.color || colors[index % colors.length]} animationProgress={animationProgress} /> {showLabels && ( <SvgText x={labelX} y={labelY} textAnchor='middle' fontSize={10} fill='#FFFFFF' fontWeight='600' alignmentBaseline='middle' > {item.value} </SvgText> )} </G> ); })} {/* Grid circles for reference */} {[0.25, 0.5, 0.75, 1].map((ratio, index) => ( <Circle key={`grid-${index}`} cx={centerX} cy={centerY} r={maxRadius * ratio} stroke={mutedColor} strokeWidth={0.5} fill='none' opacity={0.2} /> ))} </Svg> {/* Legend */} <View style={{ marginTop: 10 }}> {data.map((item, index) => ( <View key={`legend-${index}`} style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 5, }} > <View style={{ width: 12, height: 12, borderRadius: 6, backgroundColor: item.color || colors[index % colors.length], marginRight: 8, }} /> <Text variant='caption'> {item.label}: {item.value} </Text> </View> ))} </View> </View> ); }; ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { PolarAreaChart } from '@/components/charts/polar-area-chart'; ``` ```tsx const data = [ { label: 'Sales', value: 120 }, { label: 'Marketing', value: 98 }, { label: 'Support', value: 86 }, { label: 'Development', value: 140 }, { label: 'Design', value: 75 }, ]; <PolarAreaChart data={data} config={{ height: 300, showLabels: true, animated: true, }} />; ``` ## Examples #### Basic Polar Area Chart **Example:** A polar area chart with smooth animations ```tsx // components/demo/charts/polar-area-chart/polar-area-chart-demo.tsx import { PolarAreaChart } from '@/components/charts/polar-area-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const sampleData = [ { label: 'Sales', value: 120 }, { label: 'Marketing', value: 98 }, { label: 'Support', value: 86 }, { label: 'Development', value: 140 }, { label: 'Design', value: 75 }, { label: 'HR', value: 65 }, ]; export function PolarAreaChartDemo() { return ( <ChartContainer title='Department Performance' description='Quarterly performance metrics by department' > <PolarAreaChart data={sampleData} config={{ height: 300, showLabels: true, animated: true, duration: 1000, }} /> </ChartContainer> ); } ``` #### Sample Polar Area Chart **Example:** A sample polar area chart ```tsx // components/demo/charts/polar-area-chart/polar-area-chart-sample.tsx import { PolarAreaChart } from '@/components/charts/polar-area-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import { useColor } from '@/hooks/useColor'; import React from 'react'; const skillsData = [ { label: 'JavaScript', value: 95 }, { label: 'React', value: 88 }, { label: 'TypeScript', value: 82 }, { label: 'Node.js', value: 78 }, { label: 'Python', value: 65 }, ]; export function PolarAreaChartSample() { const primaryColor = useColor('primary'); const blueColor = useColor('blue'); const greenColor = useColor('green'); const orangeColor = useColor('orange'); const purpleColor = useColor('purple'); const dataWithColors = skillsData.map((item, index) => ({ ...item, color: [primaryColor, blueColor, greenColor, orangeColor, purpleColor][ index ], })); return ( <ChartContainer title='Skills Assessment' description='Technical skills proficiency levels' > <PolarAreaChart data={dataWithColors} config={{ height: 280, showLabels: true, animated: true, duration: 1200, }} /> </ChartContainer> ); } ``` #### Styled Polar Area Chart **Example:** A customized polar area chart with custom colors and styling ```tsx // components/demo/charts/polar-area-chart/polar-area-chart-styled.tsx import { PolarAreaChart } from '@/components/charts/polar-area-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import { useColor } from '@/hooks/useColor'; import React from 'react'; const marketData = [ { label: 'Mobile Apps', value: 45, color: '#FF6B6B' }, { label: 'Web Apps', value: 38, color: '#4ECDC4' }, { label: 'Desktop', value: 25, color: '#45B7D1' }, { label: 'IoT', value: 18, color: '#96CEB4' }, { label: 'AI/ML', value: 32, color: '#FFEAA7' }, { label: 'Blockchain', value: 15, color: '#DDA0DD' }, ]; export function PolarAreaChartStyled() { return ( <ChartContainer title='Market Share Analysis' description='Technology sector market distribution' > <PolarAreaChart data={marketData} config={{ height: 320, showLabels: true, animated: true, duration: 1500, }} style={{ backgroundColor: 'rgba(0, 0, 0, 0.02)', borderRadius: 12, padding: 16, }} /> </ChartContainer> ); } ``` #### Large Polar Area Chart **Example:** A polar area chart with large dataset ```tsx // components/demo/charts/polar-area-chart/polar-area-chart-large.tsx import { PolarAreaChart } from '@/components/charts/polar-area-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const largeDataset = [ { label: 'Q1 Sales', value: 185 }, { label: 'Q2 Sales', value: 220 }, { label: 'Q3 Sales', value: 195 }, { label: 'Q4 Sales', value: 240 }, { label: 'Marketing', value: 156 }, { label: 'Support', value: 134 }, { label: 'Development', value: 189 }, { label: 'Design', value: 123 }, { label: 'HR', value: 98 }, { label: 'Finance', value: 145 }, { label: 'Operations', value: 167 }, { label: 'Research', value: 112 }, ]; export function PolarAreaChartLarge() { return ( <ChartContainer title='Annual Performance Overview' description='Comprehensive performance metrics across all departments and quarters' > <PolarAreaChart data={largeDataset} config={{ height: 400, showLabels: true, animated: true, duration: 2000, }} /> </ChartContainer> ); } ``` ## API Reference ### PolarAreaChart A customizable polar area chart component with smooth animations and flexible styling. Perfect for displaying multivariate data in a radial format, where each segment represents a different category with varying magnitudes. | Prop | Type | Default | Description | | -------- | ------------------ | ------- | --------------------------------------------- | | `data` | `ChartDataPoint[]` | - | Array of data points to display on the chart. | | `config` | `ChartConfig` | `{}` | Configuration object for chart appearance. | | `style` | `ViewStyle` | - | Additional styles to apply to the chart. | ### ChartDataPoint | Prop | Type | Description | | ------- | -------- | -------------------------------------- | | `label` | `string` | The label for the data point. | | `value` | `number` | The value for the data point. | | `color` | `string` | Optional custom color for the segment. | ### ChartConfig | Prop | Type | Default | Description | | ------------ | --------- | ------- | ------------------------------------------------- | | `width` | `number` | - | Fixed width of the chart (auto-sizes if omitted). | | `height` | `number` | `200` | Height of the chart. | | `showLabels` | `boolean` | `true` | Whether to show value labels on segments. | | `animated` | `boolean` | `true` | Whether to animate the chart on load. | | `duration` | `number` | `1000` | Animation duration in milliseconds. | ## Features - **Radial Layout**: Displays data segments in a circular pattern radiating from center - **Smooth Animations**: Built-in animations using React Native Reanimated - **Responsive Design**: Automatically adapts to container width - **Custom Colors**: Support for individual segment colors - **Value Labels**: Shows values directly on chart segments - **Grid Lines**: Concentric circles provide visual reference for magnitude - **Theme Integration**: Uses theme colors for consistent styling - **Interactive Legend**: Color-coded legend with labels and values ## Use Cases Polar area charts are particularly effective for: - **Performance Metrics**: Displaying multi-dimensional performance data - **Survey Results**: Showing ratings across different categories - **Skills Assessment**: Visualizing competency levels across various skills - **Budget Allocation**: Showing spending distribution across departments - **Quality Metrics**: Displaying quality scores across different criteria - **Market Analysis**: Comparing market share or performance across segments ## Design Considerations The polar area chart design makes it ideal for: - **Comparative Analysis**: Easy visual comparison of magnitudes across categories - **Radial Data**: Natural representation of data that radiates from a central point - **Equal Categories**: All categories get equal angular space regardless of value - **Magnitude Emphasis**: Radius represents value magnitude, making differences clear - **Compact Display**: Efficient use of space for multivariate data ## Accessibility The PolarAreaChart component includes several accessibility features: - Semantic SVG structure for screen readers - High contrast white text on colored segments - Descriptive legend with clear labels and values - Proper color contrast ratios - Text labels for both categories and values - Supports dynamic text sizing ## Performance The component is optimized for performance: - Uses React Native Reanimated for smooth 60fps animations - Efficient SVG rendering with minimal re-renders - Automatic cleanup of animation values - Responsive layout calculations - Optimized path calculations for segments ## Styling The component integrates with your theme system: - Uses theme colors (primary, blue, green, orange, purple, pink) for segments - Uses `mutedForeground` color for grid lines and legend text - Supports custom colors per data point - Semi-transparent segments (80% opacity) for visual appeal - White stroke borders for segment separation ## Animation The chart features smooth entry animations: - Segments animate from 0 opacity to full opacity - Configurable animation duration (default 1000ms) - Can be disabled for instant rendering - Uses React Native Reanimated for optimal performance - Smooth transitions when data changes ## Mathematical Implementation The chart uses polar coordinates for accurate segment positioning: - Each segment occupies equal angular space (360° / number of segments) - Radius is proportional to value magnitude - Segments are drawn as SVG paths using arc commands - Grid circles provide visual reference at 25%, 50%, 75%, and 100% of max radius - Labels are positioned at 70% of segment radius for optimal readability ## Comparison with Other Chart Types **Polar Area Chart vs Pie Chart:** - Polar area: Equal angles, varying radius (emphasizes magnitude) - Pie chart: Varying angles, equal radius (emphasizes proportion) **Polar Area Chart vs Radar Chart:** - Polar area: Filled segments, individual values - Radar chart: Connected lines, relationship between values **Polar Area Chart vs Bar Chart:** - Polar area: Radial layout, compact display - Bar chart: Linear layout, easier value comparison <!-- ---------------------------------------------------------------------- --> # Progress Ring Chart > A customizable circular progress ring component with smooth animations and flexible styling. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/charts/progress-ring-chart - Markdown: https://ui.ahmedbna.com/docs/charts/progress-ring-chart.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/progress-ring-chart.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/progress-ring-chart.json - Install: `npx bna-ui add progress-ring-chart` - npm dependencies: `react-native-reanimated`, `react-native-svg`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `text` - Preview recording: https://demo.ahmedbna.com/0371-progress-ring-chart-demo.MOV --- **Example:** A circular progress ring with smooth animations ```tsx // components/demo/charts/progress-ring-chart/progress-ring-chart-demo.tsx import { ProgressRingChart } from '@/components/charts/progress-ring-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; export function ProgressRingChartDemo() { return ( <ChartContainer title='Goal Progress' description='Track your progress towards your goals' > <ProgressRingChart progress={75} size={120} strokeWidth={8} config={{ animated: true, duration: 1000, gradient: false, }} showLabel={true} label='Completion Rate' /> </ChartContainer> ); } ``` ## Installation ### CLI ```bash npx bna-ui add progress-ring-chart ``` ### Manual **1.** Install the required dependencies. ```bash npm install react-native-svg react-native-reanimated react-native-worklets ``` **2.** Copy and paste the following code into your project. ```tsx // components/charts/progress-ring-chart.tsx import { Text } from '@/components/ui/text'; import { useColor } from '@/hooks/useColor'; import { useEffect, useId } from 'react'; import { View, ViewStyle } from 'react-native'; import Animated, { useAnimatedProps, useSharedValue, withTiming, } from 'react-native-reanimated'; import Svg, { Circle, Defs, LinearGradient, Stop, Text as SvgText, } from 'react-native-svg'; // Animated SVG Components const AnimatedCircle = Animated.createAnimatedComponent(Circle); interface ChartConfig { animated?: boolean; duration?: number; gradient?: boolean; } type Props = { progress: number; // 0-100 size?: number; strokeWidth?: number; config?: ChartConfig; style?: ViewStyle; showLabel?: boolean; label?: string; centerText?: string; }; export const ProgressRingChart = ({ progress, size = 120, strokeWidth = 8, config = {}, style, showLabel = true, label, centerText, }: Props) => { const { animated = true, duration = 1000, gradient = false } = config; const primaryColor = useColor('primary'); const mutedColor = useColor('mutedForeground'); const animationProgress = useSharedValue(0); // Namespaced so multiple same-config rings on one screen don't collide on // a shared literal gradient id. const gradientId = `progressGradient-${useId()}`; const clampedProgress = Math.max(0, Math.min(100, progress)); useEffect(() => { if (animated) { animationProgress.value = withTiming(1, { duration }); } else { animationProgress.value = 1; } }, [clampedProgress, animated, duration]); const radius = (size - strokeWidth) / 2; const circumference = 2 * Math.PI * radius; const center = size / 2; const progressAnimatedProps = useAnimatedProps(() => { const animatedProgress = animationProgress.value * (clampedProgress / 100); const strokeDashoffset = circumference - animatedProgress * circumference; return { strokeDashoffset, }; }); return ( <View style={[{ alignItems: 'center' }, style]} accessible accessibilityRole='progressbar' accessibilityValue={{ min: 0, max: 100, now: clampedProgress }} accessibilityLabel={label} > {showLabel && label && ( <Text variant='caption' style={{ color: mutedColor, fontWeight: '600', marginBottom: 4 }} > {label} </Text> )} <Svg width={size} height={size}> <Defs> {gradient && ( <LinearGradient id={gradientId} x1='0%' y1='0%' x2='100%' y2='0%'> <Stop offset='0%' stopColor={primaryColor} stopOpacity='0.3' /> <Stop offset='100%' stopColor={primaryColor} stopOpacity='1' /> </LinearGradient> )} </Defs> {/* Background circle */} <Circle cx={center} cy={center} r={radius} stroke={mutedColor} strokeWidth={strokeWidth} fill='none' opacity={0.2} /> {/* Progress circle */} <AnimatedCircle cx={center} cy={center} r={radius} stroke={gradient ? `url(#${gradientId})` : primaryColor} strokeWidth={strokeWidth} fill='none' strokeLinecap='round' strokeDasharray={circumference} transform={`rotate(-90 ${center} ${center})`} animatedProps={progressAnimatedProps} /> {/* Center text */} {centerText && ( <SvgText x={center} y={center + 6} textAnchor='middle' fontSize={18} fill={primaryColor} fontWeight='bold' > {centerText} </SvgText> )} {/* Progress percentage */} {!centerText && ( <SvgText x={center} y={center + 6} textAnchor='middle' fontSize={16} fill={primaryColor} fontWeight='600' > {Math.round(clampedProgress)}% </SvgText> )} </Svg> </View> ); }; ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { ProgressRingChart } from '@/components/charts/progress-ring-chart'; ``` ```tsx <ProgressRingChart progress={75} size={120} strokeWidth={8} config={{ animated: true, duration: 1000, gradient: true, }} /> ``` ## Examples #### Basic Progress Ring **Example:** A circular progress ring with smooth animations ```tsx // components/demo/charts/progress-ring-chart/progress-ring-chart-demo.tsx import { ProgressRingChart } from '@/components/charts/progress-ring-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; export function ProgressRingChartDemo() { return ( <ChartContainer title='Goal Progress' description='Track your progress towards your goals' > <ProgressRingChart progress={75} size={120} strokeWidth={8} config={{ animated: true, duration: 1000, gradient: false, }} showLabel={true} label='Completion Rate' /> </ChartContainer> ); } ``` #### Sample Progress Ring **Example:** A sample progress ring chart ```tsx // components/demo/charts/progress-ring-chart/progress-ring-chart-sample.tsx import { ProgressRingChart } from '@/components/charts/progress-ring-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; export function ProgressRingChartSample() { return ( <ChartContainer title='Daily Steps' description='Track your daily step count' > <ProgressRingChart progress={68} size={140} strokeWidth={10} config={{ animated: true, duration: 1500, gradient: false, }} showLabel={true} label='Daily Goal' centerText='6,800' /> </ChartContainer> ); } ``` #### Styled Progress Ring **Example:** A customized progress ring with gradient and custom styling ```tsx // components/demo/charts/progress-ring-chart/progress-ring-chart-styled.tsx import { ProgressRingChart } from '@/components/charts/progress-ring-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; export function ProgressRingChartStyled() { return ( <ChartContainer title='Project Progress' description='Development milestone completion with gradient styling' > <ProgressRingChart progress={92} size={160} strokeWidth={12} config={{ animated: true, duration: 2000, gradient: true, }} showLabel={true} label='Sprint Progress' centerText='92%' /> </ChartContainer> ); } ``` #### Large Progress Ring **Example:** A large progress ring with center text ```tsx // components/demo/charts/progress-ring-chart/progress-ring-chart-large.tsx import { ProgressRingChart } from '@/components/charts/progress-ring-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; export function ProgressRingChartLarge() { return ( <ChartContainer title='Annual Revenue Target' description='Track progress towards annual revenue goals' > <ProgressRingChart progress={87} size={200} strokeWidth={16} config={{ animated: true, duration: 2500, gradient: true, }} showLabel={true} label='Revenue Target' centerText='$2.6M' /> </ChartContainer> ); } ``` ## API Reference ### ProgressRingChart A customizable circular progress ring component with smooth animations and flexible styling. Perfect for displaying progress, completion rates, or any percentage-based data. | Prop | Type | Default | Description | | ------------- | ------------- | ------- | ------------------------------------------ | | `progress` | `number` | - | Progress value from 0 to 100. | | `size` | `number` | `120` | Size of the progress ring in pixels. | | `strokeWidth` | `number` | `8` | Width of the progress ring stroke. | | `config` | `ChartConfig` | `{}` | Configuration object for chart appearance. | | `style` | `ViewStyle` | - | Additional styles to apply to the chart. | | `showLabel` | `boolean` | `true` | Whether to show the label above the ring. | | `label` | `string` | - | Label text to display above the ring. | | `centerText` | `string` | - | Custom text to display in the center. | ### ChartConfig | Prop | Type | Default | Description | | ---------- | --------- | ------- | -------------------------------------------- | | `animated` | `boolean` | `true` | Whether to animate the chart on load. | | `duration` | `number` | `1000` | Animation duration in milliseconds. | | `gradient` | `boolean` | `false` | Whether to use gradient colors for the ring. | ## Features - **Circular Design**: Clean circular progress indicator - **Smooth Animations**: Built-in animations using React Native Reanimated - **Gradient Support**: Optional gradient colors for enhanced visual appeal - **Center Text**: Display custom text or percentage in the center - **Label Support**: Optional label above the progress ring - **Theme Integration**: Uses theme colors for consistent styling - **Responsive**: Automatically adapts to different sizes ## Use Cases Progress ring charts are particularly effective for: - **Progress Tracking**: Displaying completion rates, loading progress - **Performance Metrics**: Showing KPIs, scores, or achievements - **Goal Tracking**: Visualizing progress towards targets - **Health Metrics**: Displaying fitness goals, step counters - **Dashboard Widgets**: Compact progress indicators for dashboards ## Design Considerations The circular design of the ProgressRingChart makes it ideal for: - **Compact Displays**: Efficient use of space with circular design - **Dashboard Widgets**: Perfect for small dashboard components - **Mobile Interfaces**: Works well on small screens - **Visual Hierarchy**: Draws attention to important metrics - **Progress Visualization**: Intuitive representation of completion ## Accessibility The ProgressRingChart component includes several accessibility features: - Semantic SVG structure for screen readers - Proper contrast ratios for visual elements - Text labels for progress values - Supports dynamic text sizing - Keyboard navigation support ## Performance The component is optimized for performance: - Uses React Native Reanimated for smooth 60fps animations - Efficient SVG rendering with minimal re-renders - Automatic cleanup of animation values - Lightweight circular path calculations ## Styling The component integrates with your theme system: - Uses `primary` color from theme for default ring color - Uses `mutedForeground` color for labels and text - Supports gradient colors for enhanced visual appeal - Rounded stroke caps for modern appearance ## Animation The chart features smooth entry animations: - Ring animates from 0% to target progress - Configurable animation duration - Can be disabled for instant rendering - Uses React Native Reanimated for optimal performance ## Customization The progress ring can be customized in various ways: - **Size**: Adjust the overall size of the ring - **Stroke Width**: Control the thickness of the ring - **Colors**: Use theme colors or custom gradient - **Center Content**: Display percentage or custom text - **Labels**: Add descriptive labels above the ring <!-- ---------------------------------------------------------------------- --> # Radar Chart > A customizable radar chart component with smooth animations and flexible styling for displaying multi-dimensional data. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/charts/radar-chart - Markdown: https://ui.ahmedbna.com/docs/charts/radar-chart.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/radar-chart.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/radar-chart.json - Install: `npx bna-ui add radar-chart` - npm dependencies: `react-native-reanimated`, `react-native-svg`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals` - Preview recording: https://demo.ahmedbna.com/0375-radar-chart-demo.MOV --- **Example:** A radar chart with smooth animations ```tsx // components/demo/charts/radar-chart/radar-chart-demo.tsx import { RadarChart } from '@/components/charts/radar-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const sampleData = [ { label: 'Speed', value: 80 }, { label: 'Reliability', value: 92 }, { label: 'Comfort', value: 75 }, { label: 'Safety', value: 88 }, { label: 'Efficiency', value: 85 }, { label: 'Style', value: 70 }, ]; export function RadarChartDemo() { return ( <ChartContainer title='Product Performance' description='Multi-dimensional performance analysis across key metrics' > <RadarChart data={sampleData} config={{ height: 300, showLabels: true, animated: true, duration: 1000, }} /> </ChartContainer> ); } ``` ## Installation ### CLI ```bash npx bna-ui add radar-chart ``` ### Manual **1.** Install the required dependencies. ```bash npm install react-native-svg react-native-reanimated react-native-worklets ``` **2.** Copy and paste the following code into your project. ```tsx // components/charts/radar-chart.tsx import { useColor } from '@/hooks/useColor'; import React, { useEffect, useState } from 'react'; import { LayoutChangeEvent, View, ViewStyle } from 'react-native'; import Animated, { SharedValue, useAnimatedProps, useSharedValue, withDelay, withSpring, withTiming, } from 'react-native-reanimated'; import Svg, { Circle, Line, Path, Text as SvgText } from 'react-native-svg'; // Animated SVG Components const AnimatedPath = Animated.createAnimatedComponent(Path); const AnimatedCircle = Animated.createAnimatedComponent(Circle); type AnimatedVertexProps = { cx: number; cy: number; fill: string; index: number; animationProgress: SharedValue<number>; }; // Per-item hook must live in its own mounted subcomponent, not in the // parent's .map() body — calling useAnimatedProps per loop iteration // violates Rules of Hooks the moment data.length changes. const AnimatedVertex = React.memo( ({ cx, cy, fill, index, animationProgress }: AnimatedVertexProps) => { const pointAnimatedProps = useAnimatedProps(() => ({ opacity: animationProgress.value, r: withDelay(index * 100, withSpring(animationProgress.value * 4)), })); return ( <AnimatedCircle cx={cx} cy={cy} fill={fill} animatedProps={pointAnimatedProps} /> ); } ); interface ChartConfig { width?: number; height?: number; showLabels?: boolean; animated?: boolean; duration?: number; maxValue?: number; } interface RadarChartDataPoint { label: string; value: number; } type Props = { data: RadarChartDataPoint[]; config?: ChartConfig; style?: ViewStyle; }; export const RadarChart = ({ data, config = {}, style }: Props) => { const [containerWidth, setContainerWidth] = useState(300); const { height = 200, showLabels = true, animated = true, duration = 1000, maxValue, } = config; const chartWidth = containerWidth || config.width || 300; const primaryColor = useColor('primary'); const mutedColor = useColor('mutedForeground'); const animationProgress = useSharedValue(0); const handleLayout = (event: LayoutChangeEvent) => { const { width: measuredWidth } = event.nativeEvent.layout; if (measuredWidth > 0) { setContainerWidth(measuredWidth); } }; useEffect(() => { if (animated) { animationProgress.value = withTiming(1, { duration }); } else { animationProgress.value = 1; } }, [data, animated, duration]); if (!data.length) return null; const centerX = chartWidth / 2; const centerY = height / 2; const radius = Math.min(chartWidth, height) / 2 - 40; // `??` (not `||`) so an explicit maxValue={0} isn't silently discarded. const maxVal = maxValue ?? Math.max(...data.map((d) => d.value)); if (maxVal === 0) return null; // Calculate points for each data point const angleStep = (2 * Math.PI) / data.length; const points = data.map((item, index) => { const angle = index * angleStep - Math.PI / 2; // Start from top const distance = (item.value / maxVal) * radius; return { x: centerX + distance * Math.cos(angle), y: centerY + distance * Math.sin(angle), labelX: centerX + (radius + 20) * Math.cos(angle), labelY: centerY + (radius + 20) * Math.sin(angle), label: item.label, }; }); // Create path for the radar area const radarPath = points.length > 0 ? `M${points[0].x},${points[0].y} ` + points .slice(1) .map((p) => `L${p.x},${p.y}`) .join(' ') + ' Z' : ''; const radarAnimatedProps = useAnimatedProps(() => ({ opacity: animationProgress.value * 0.3, })); return ( <View style={[{ width: '100%', height }, style]} onLayout={handleLayout} accessibilityRole='image' accessibilityLabel={`Radar chart with ${data.length} axes, maximum value ${Math.round(maxVal)}`} > <Svg width={chartWidth} height={height}> {/* Grid circles */} {[0.2, 0.4, 0.6, 0.8, 1].map((ratio, index) => ( <Circle key={`grid-circle-${index}`} cx={centerX} cy={centerY} r={radius * ratio} stroke={mutedColor} strokeWidth={0.5} fill='none' opacity={0.3} /> ))} {/* Grid lines */} {data.map((_, index) => { const angle = index * angleStep - Math.PI / 2; const endX = centerX + radius * Math.cos(angle); const endY = centerY + radius * Math.sin(angle); return ( <Line key={`grid-line-${index}`} x1={centerX} y1={centerY} x2={endX} y2={endY} stroke={mutedColor} strokeWidth={0.5} opacity={0.3} /> ); })} {/* Radar area */} <AnimatedPath d={radarPath} fill={primaryColor} stroke={primaryColor} strokeWidth={2} animatedProps={radarAnimatedProps} /> {/* Data points */} {points.map((point, index) => ( <AnimatedVertex key={`point-${index}`} cx={point.x} cy={point.y} fill={primaryColor} index={index} animationProgress={animationProgress} /> ))} {/* Labels */} {showLabels && points.map((point, index) => ( <SvgText key={`label-${index}`} x={point.labelX} y={point.labelY} textAnchor='middle' fontSize={12} fill={mutedColor} alignmentBaseline='middle' > {point.label} </SvgText> ))} </Svg> </View> ); }; ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { RadarChart } from '@/components/charts/radar-chart'; ``` ```tsx const data = [ { label: 'Speed', value: 80 }, { label: 'Reliability', value: 92 }, { label: 'Comfort', value: 75 }, { label: 'Safety', value: 88 }, { label: 'Efficiency', value: 85 }, ]; <RadarChart data={data} config={{ height: 300, showLabels: true, animated: true, }} />; ``` ## Examples #### Basic Radar Chart **Example:** A radar chart with smooth animations ```tsx // components/demo/charts/radar-chart/radar-chart-demo.tsx import { RadarChart } from '@/components/charts/radar-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const sampleData = [ { label: 'Speed', value: 80 }, { label: 'Reliability', value: 92 }, { label: 'Comfort', value: 75 }, { label: 'Safety', value: 88 }, { label: 'Efficiency', value: 85 }, { label: 'Style', value: 70 }, ]; export function RadarChartDemo() { return ( <ChartContainer title='Product Performance' description='Multi-dimensional performance analysis across key metrics' > <RadarChart data={sampleData} config={{ height: 300, showLabels: true, animated: true, duration: 1000, }} /> </ChartContainer> ); } ``` #### Sample Radar Chart **Example:** A sample radar chart ```tsx // components/demo/charts/radar-chart/radar-chart-sample.tsx import { RadarChart } from '@/components/charts/radar-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const skillsData = [ { label: 'Frontend', value: 95 }, { label: 'Backend', value: 82 }, { label: 'Mobile', value: 78 }, { label: 'DevOps', value: 65 }, { label: 'Design', value: 70 }, ]; export function RadarChartSample() { return ( <ChartContainer title='Skills Assessment' description='Developer competency across different technology areas' > <RadarChart data={skillsData} config={{ height: 250, showLabels: true, animated: true, duration: 1200, maxValue: 100, }} /> </ChartContainer> ); } ``` #### Styled Radar Chart **Example:** A customized radar chart with custom colors and styling ```tsx // components/demo/charts/radar-chart/radar-chart-styled.tsx import { RadarChart } from '@/components/charts/radar-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import { useColor } from '@/hooks/useColor'; import React from 'react'; const performanceData = [ { label: 'Innovation', value: 88 }, { label: 'Quality', value: 92 }, { label: 'Delivery', value: 85 }, { label: 'Customer Satisfaction', value: 90 }, { label: 'Cost Efficiency', value: 78 }, { label: 'Team Collaboration', value: 95 }, { label: 'Process Improvement', value: 82 }, ]; export function RadarChartStyled() { const accentColor = useColor('accent'); return ( <ChartContainer title='Team Performance Matrix' description='Comprehensive evaluation across key performance indicators' > <RadarChart data={performanceData} config={{ height: 350, showLabels: true, animated: true, duration: 1500, maxValue: 100, }} style={{ backgroundColor: 'rgba(0, 0, 0, 0.02)', borderRadius: 12, padding: 16, }} /> </ChartContainer> ); } ``` #### Large Radar Chart **Example:** A radar chart with large dataset ```tsx // components/demo/charts/radar-chart/radar-chart-large.tsx import { RadarChart } from '@/components/charts/radar-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const comprehensiveData = [ { label: 'Leadership', value: 85 }, { label: 'Communication', value: 90 }, { label: 'Technical Skills', value: 88 }, { label: 'Problem Solving', value: 92 }, { label: 'Creativity', value: 78 }, { label: 'Adaptability', value: 86 }, { label: 'Time Management', value: 82 }, { label: 'Teamwork', value: 94 }, { label: 'Strategic Thinking', value: 80 }, { label: 'Customer Focus', value: 87 }, ]; export function RadarChartLarge() { return ( <ChartContainer title='360° Skills Assessment' description='Comprehensive evaluation across multiple competency areas' > <RadarChart data={comprehensiveData} config={{ height: 400, showLabels: true, animated: true, duration: 2000, maxValue: 100, }} /> </ChartContainer> ); } ``` ## API Reference ### RadarChart A customizable radar chart component with smooth animations and flexible styling. Perfect for displaying multi-dimensional data with emphasis on comparison across multiple metrics. | Prop | Type | Default | Description | | -------- | ----------------------- | ------- | --------------------------------------------- | | `data` | `RadarChartDataPoint[]` | - | Array of data points to display on the chart. | | `config` | `ChartConfig` | `{}` | Configuration object for chart appearance. | | `style` | `ViewStyle` | - | Additional styles to apply to the chart. | ### RadarChartDataPoint | Prop | Type | Description | | ------- | -------- | ----------------------------- | | `label` | `string` | The label for the data point. | | `value` | `number` | The value for the data point. | ### ChartConfig | Prop | Type | Default | Description | | ------------ | --------- | ------- | ---------------------------------------------------- | | `width` | `number` | - | Fixed width of the chart (auto-sizes if omitted). | | `height` | `number` | `200` | Height of the chart. | | `showLabels` | `boolean` | `true` | Whether to show labels around the chart. | | `animated` | `boolean` | `true` | Whether to animate the chart on load. | | `duration` | `number` | `1000` | Animation duration in milliseconds. | | `maxValue` | `number` | - | Maximum value for the chart scale (auto if omitted). | ## Features - **Circular Layout**: Displays data points in a circular radar pattern - **Smooth Animations**: Built-in animations using React Native Reanimated - **Responsive Design**: Automatically adapts to container width - **Grid System**: Circular grid lines and radial guides for easy reading - **Label Display**: Shows category labels around the perimeter - **Theme Integration**: Uses theme colors for consistent styling - **Filled Area**: Highlighted area showing the data profile ## Use Cases Radar charts are particularly effective for: - **Performance Analysis**: Comparing multiple performance metrics - **Skill Assessment**: Visualizing competency across different areas - **Product Comparison**: Comparing features across multiple products - **Survey Results**: Displaying multi-dimensional survey responses - **Sports Analytics**: Showing player statistics across different attributes - **Quality Metrics**: Displaying quality scores across various dimensions ## Design Considerations The circular layout of the RadarChart makes it ideal for: - **Multi-dimensional Data**: Perfect for displaying 3-8 different metrics - **Pattern Recognition**: Easy to spot strengths and weaknesses - **Comparative Analysis**: Overlaying multiple data sets for comparison - **Balance Visualization**: Showing how balanced performance is across metrics ## Accessibility The RadarChart component includes several accessibility features: - Semantic SVG structure for screen readers - Proper contrast ratios for visual elements - Text labels for all data points - Supports dynamic text sizing - Clear visual hierarchy with grid lines ## Performance The component is optimized for performance: - Uses React Native Reanimated for smooth 60fps animations - Efficient SVG rendering with minimal re-renders - Automatic cleanup of animation values - Responsive layout calculations - Optimized path calculations for smooth rendering ## Styling The component integrates with your theme system: - Uses `primary` color from theme for default area fill and stroke - Uses `mutedForeground` color for labels and grid lines - Supports custom styling through style prop - Consistent opacity and visual hierarchy ## Animation The chart features smooth entry animations: - Area fills from 0 opacity to final opacity - Data points animate with staggered delays - Configurable animation duration - Can be disabled for instant rendering - Uses React Native Reanimated for optimal performance ## Mathematical Considerations The radar chart uses polar coordinates: - Angles are calculated based on the number of data points - Values are normalized to fit within the chart radius - Grid circles represent percentage increments (20%, 40%, 60%, 80%, 100%) - Labels are positioned outside the chart area for clarity ## Best Practices When using radar charts: - **Limit Data Points**: Use 3-8 metrics for optimal readability - **Similar Scales**: Ensure all metrics use similar value ranges - **Meaningful Order**: Arrange metrics in logical order around the circle - **Clear Labels**: Use concise, descriptive labels - **Consistent Units**: Use consistent measurement units across metrics <!-- ---------------------------------------------------------------------- --> # Radial Bar Chart > A customizable radial bar chart component with smooth animations, gradient support, and center value display. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/charts/radial-bar-chart - Markdown: https://ui.ahmedbna.com/docs/charts/radial-bar-chart.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/radial-bar-chart.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/radial-bar-chart.json - Install: `npx bna-ui add radial-bar-chart` - npm dependencies: `react-native-reanimated`, `react-native-svg`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals`, `text` - Preview recording: https://demo.ahmedbna.com/0379-radial-bar-chart-demo.MOV --- **Example:** A radial bar chart with smooth animations and center totals ```tsx // components/demo/charts/radial-bar-chart/radial-bar-chart-demo.tsx import { RadialBarChart } from '@/components/charts/radial-bar-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const sampleData = [ { label: 'Sales', value: 120 }, { label: 'Marketing', value: 98 }, { label: 'Support', value: 86 }, { label: 'Development', value: 140 }, { label: 'Design', value: 75 }, ]; export function RadialBarChartDemo() { return ( <ChartContainer title='Department Performance' description='Quarterly performance metrics by department' > <RadialBarChart data={sampleData} config={{ animated: true, duration: 1000, gradient: false, }} /> </ChartContainer> ); } ``` ## Installation ### CLI ```bash npx bna-ui add radial-bar-chart ``` ### Manual **1.** Install the required dependencies. ```bash npm install react-native-svg react-native-reanimated react-native-worklets ``` **2.** Copy and paste the following code into your project. ```tsx // components/charts/radial-bar-chart.tsx import { Text } from '@/components/ui/text'; import { useColor } from '@/hooks/useColor'; import React, { useEffect, useId, useState } from 'react'; import { LayoutChangeEvent, View, ViewStyle } from 'react-native'; import Animated, { SharedValue, useAnimatedProps, useSharedValue, withTiming, } from 'react-native-reanimated'; import Svg, { Circle, Defs, LinearGradient, Stop, Text as SvgText, } from 'react-native-svg'; // Animated SVG Components const AnimatedCircle = Animated.createAnimatedComponent(Circle); type AnimatedRadialBarProps = { cx: number; cy: number; r: number; stroke: string; strokeWidth: number; circumference: number; progressRatio: number; transform: string; animationProgress: SharedValue<number>; }; // Per-item hook must live in its own mounted subcomponent, not in the // parent's .map() body — calling useAnimatedProps per loop iteration // violates Rules of Hooks the moment data.length changes. const AnimatedRadialBar = React.memo( ({ cx, cy, r, stroke, strokeWidth, circumference, progressRatio, transform, animationProgress, }: AnimatedRadialBarProps) => { const circleAnimatedProps = useAnimatedProps(() => { const animatedProgress = animationProgress.value * progressRatio; const strokeDashoffset = circumference - animatedProgress * circumference; return { strokeDashoffset }; }); return ( <AnimatedCircle cx={cx} cy={cy} r={r} stroke={stroke} strokeWidth={strokeWidth} fill='none' strokeLinecap='round' strokeDasharray={circumference} transform={transform} animatedProps={circleAnimatedProps} /> ); } ); interface ChartConfig { padding?: number; animated?: boolean; duration?: number; gradient?: boolean; } interface ChartDataPoint { label: string; value: number; color?: string; } type Props = { data: ChartDataPoint[]; config?: ChartConfig; style?: ViewStyle; }; export const RadialBarChart = ({ data, config = {}, style }: Props) => { const [containerSize, setContainerSize] = useState(200); const { padding = 20, animated = true, duration = 1000, gradient = false, } = config; const primaryColor = useColor('primary'); const mutedColor = useColor('mutedForeground'); const animationProgress = useSharedValue(0); // Namespaced so multiple same-config charts on one screen don't collide // on shared literal gradient ids. const gradientIdPrefix = useId(); const handleLayout = (event: LayoutChangeEvent) => { const { width, height } = event.nativeEvent.layout; const size = Math.min(width, height); if (size > 0) { setContainerSize(size); } }; useEffect(() => { if (animated) { animationProgress.value = withTiming(1, { duration }); } else { animationProgress.value = 1; } }, [data, animated, duration]); if (!data.length) return null; const maxValue = Math.max(...data.map((d) => d.value)); if (maxValue === 0) return null; const size = containerSize || 200; const center = size / 2; const maxRadius = (size - padding * 2) / 2; const strokeWidth = maxRadius / (data.length + 1); const colors = [ primaryColor, useColor('blue'), useColor('green'), useColor('orange'), useColor('purple'), useColor('pink'), ]; return ( <View style={[{ width: '100%' }, style]} accessibilityRole='image' accessibilityLabel={`Radial bar chart with ${data.length} bars, maximum value ${Math.round(maxValue)}`} > <View style={{ width: '100%', height: size, alignItems: 'center', justifyContent: 'center', }} onLayout={handleLayout} > <Svg width={size} height={size}> <Defs> {gradient && data.map((item, index) => ( <LinearGradient key={`gradient-${index}`} id={`radialGradient-${gradientIdPrefix}-${index}`} x1='0%' y1='0%' x2='100%' y2='0%' > <Stop offset='0%' stopColor={item.color || colors[index % colors.length]} stopOpacity='0.3' /> <Stop offset='100%' stopColor={item.color || colors[index % colors.length]} stopOpacity='1' /> </LinearGradient> ))} </Defs> {data.map((item, index) => { const radius = maxRadius - index * strokeWidth - strokeWidth / 2; const circumference = 2 * Math.PI * radius; const progressRatio = item.value / maxValue; return ( <AnimatedRadialBar key={`radial-${index}`} cx={center} cy={center} r={radius} stroke={ gradient ? `url(#radialGradient-${gradientIdPrefix}-${index})` : item.color || colors[index % colors.length] } strokeWidth={strokeWidth * 0.8} circumference={circumference} progressRatio={progressRatio} transform={`rotate(-90 ${center} ${center})`} animationProgress={animationProgress} /> ); })} {/* Center values */} {data.length > 0 && ( <> <SvgText x={center} y={center - 5} textAnchor='middle' fontSize={16} fill={primaryColor} fontWeight='bold' > {data.reduce((sum, item) => sum + item.value, 0)} </SvgText> <SvgText x={center} y={center + 15} textAnchor='middle' fontSize={12} fill={mutedColor} > Total </SvgText> </> )} </Svg> </View> {/* Legend */} <View style={{ marginTop: 15 }}> {data.map((item, index) => ( <View key={`legend-${index}`} style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 8, }} > <View style={{ width: 12, height: 12, borderRadius: 6, backgroundColor: item.color || colors[index % colors.length], marginRight: 10, }} /> <Text variant='caption'> {item.label}: {item.value} </Text> </View> ))} </View> </View> ); }; ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { RadialBarChart } from '@/components/charts/radial-bar-chart'; ``` ```tsx const data = [ { label: 'Sales', value: 120 }, { label: 'Marketing', value: 98 }, { label: 'Support', value: 86 }, { label: 'Development', value: 140 }, ]; <RadialBarChart data={data} config={{ animated: true, gradient: true, duration: 1000, }} />; ``` ## Examples #### Basic Radial Bar Chart **Example:** A radial bar chart with smooth animations and center totals ```tsx // components/demo/charts/radial-bar-chart/radial-bar-chart-demo.tsx import { RadialBarChart } from '@/components/charts/radial-bar-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const sampleData = [ { label: 'Sales', value: 120 }, { label: 'Marketing', value: 98 }, { label: 'Support', value: 86 }, { label: 'Development', value: 140 }, { label: 'Design', value: 75 }, ]; export function RadialBarChartDemo() { return ( <ChartContainer title='Department Performance' description='Quarterly performance metrics by department' > <RadialBarChart data={sampleData} config={{ animated: true, duration: 1000, gradient: false, }} /> </ChartContainer> ); } ``` #### Sample Radial Bar Chart **Example:** A sample radial bar chart with custom data ```tsx // components/demo/charts/radial-bar-chart/radial-bar-chart-sample.tsx import { RadialBarChart } from '@/components/charts/radial-bar-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import { useColor } from '@/hooks/useColor'; import React from 'react'; const sampleData = [ { label: 'Mobile', value: 45 }, { label: 'Desktop', value: 38 }, { label: 'Tablet', value: 17 }, ]; export function RadialBarChartSample() { const blue = useColor('blue'); const green = useColor('green'); const orange = useColor('orange'); const dataWithColors = sampleData.map((item, index) => ({ ...item, color: [blue, green, orange][index], })); return ( <ChartContainer title='Device Usage' description='User engagement by device type' > <RadialBarChart data={dataWithColors} config={{ animated: true, duration: 1200, padding: 25, }} /> </ChartContainer> ); } ``` #### Gradient Radial Bar Chart **Example:** A radial bar chart with gradient effects ```tsx // components/demo/charts/radial-bar-chart/radial-bar-chart-gradient.tsx import { RadialBarChart } from '@/components/charts/radial-bar-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import { useColor } from '@/hooks/useColor'; import React from 'react'; const sampleData = [ { label: 'Q1 Revenue', value: 85 }, { label: 'Q2 Revenue', value: 92 }, { label: 'Q3 Revenue', value: 78 }, { label: 'Q4 Revenue', value: 96 }, ]; export function RadialBarChartGradient() { const purple = useColor('purple'); const pink = useColor('pink'); const blue = useColor('blue'); const green = useColor('green'); const dataWithColors = sampleData.map((item, index) => ({ ...item, color: [purple, pink, blue, green][index], })); return ( <ChartContainer title='Quarterly Revenue' description='Revenue performance with gradient effects' > <RadialBarChart data={dataWithColors} config={{ animated: true, duration: 1500, gradient: true, padding: 30, }} /> </ChartContainer> ); } ``` #### Large Radial Bar Chart **Example:** A radial bar chart with large dataset ```tsx // components/demo/charts/radial-bar-chart/radial-bar-chart-large.tsx import { RadialBarChart } from '@/components/charts/radial-bar-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const largeDataset = [ { label: 'Product A', value: 156 }, { label: 'Product B', value: 142 }, { label: 'Product C', value: 98 }, { label: 'Product D', value: 124 }, { label: 'Product E', value: 89 }, { label: 'Product F', value: 167 }, { label: 'Product G', value: 78 }, { label: 'Product H', value: 134 }, ]; export function RadialBarChartLarge() { return ( <ChartContainer title='Product Performance' description='Sales performance across all product lines' > <RadialBarChart data={largeDataset} config={{ animated: true, duration: 2000, padding: 15, }} /> </ChartContainer> ); } ``` ## API Reference ### RadialBarChart A customizable radial bar chart component with smooth animations, gradient support, and center value display. Perfect for displaying progress, completion rates, or categorical data in a circular format. | Prop | Type | Default | Description | | -------- | ------------------ | ------- | --------------------------------------------- | | `data` | `ChartDataPoint[]` | - | Array of data points to display on the chart. | | `config` | `ChartConfig` | `{}` | Configuration object for chart appearance. | | `style` | `ViewStyle` | - | Additional styles to apply to the chart. | ### ChartDataPoint | Prop | Type | Description | | ------- | -------- | ---------------------------------- | | `label` | `string` | The label for the data point. | | `value` | `number` | The value for the data point. | | `color` | `string` | Optional custom color for the arc. | ### ChartConfig | Prop | Type | Default | Description | | ---------- | --------- | ------- | --------------------------------------------- | | `padding` | `number` | `20` | Padding around the chart. | | `animated` | `boolean` | `true` | Whether to animate the chart on load. | | `duration` | `number` | `1000` | Animation duration in milliseconds. | | `gradient` | `boolean` | `false` | Whether to use gradient effects for the arcs. | ## Features - **Circular Layout**: Displays data as concentric circles radiating from center - **Smooth Animations**: Built-in animations using React Native Reanimated - **Responsive Design**: Automatically adapts to container size - **Custom Colors**: Support for individual arc colors - **Gradient Support**: Optional gradient effects for enhanced visual appeal - **Center Display**: Shows total value and label in the center - **Legend**: Automatic legend generation with color indicators - **Theme Integration**: Uses theme colors for consistent styling ## Use Cases Radial bar charts are particularly effective for: - **Progress Tracking**: Displaying completion rates or goal progress - **Category Comparison**: Comparing values across different categories in a compact format - **Dashboard Widgets**: Space-efficient data visualization for dashboards - **Performance Metrics**: Showing KPIs, scores, or ratings in a visually appealing way - **Budget Allocation**: Visualizing spending distributions - **Survey Results**: Displaying response distributions in a circular format ## Design Considerations The radial layout of the RadialBarChart makes it ideal for: - **Compact Spaces**: Efficient use of space with circular design - **Multiple Categories**: Clear visual separation with concentric circles - **Progress Visualization**: Natural representation of completion or progress - **Aesthetic Appeal**: Visually striking and modern appearance ## Accessibility The RadialBarChart component includes several accessibility features: - Semantic SVG structure for screen readers - Proper contrast ratios for visual elements - Text labels for both categories and values - Legend with clear color indicators - Supports dynamic text sizing - Keyboard navigation support ## Performance The component is optimized for performance: - Uses React Native Reanimated for smooth 60fps animations - Efficient SVG rendering with minimal re-renders - Automatic cleanup of animation values - Responsive layout calculations - Optimized gradient rendering ## Styling The component integrates with your theme system: - Uses `primary` color from theme for default arc color - Uses `mutedForeground` color for labels and text - Supports custom colors per data point - Gradient effects with customizable opacity - Rounded stroke caps for modern appearance ## Animation The chart features smooth entry animations: - Arcs animate from 0 to full progress - Configurable animation duration - Can be disabled for instant rendering - Uses React Native Reanimated for optimal performance - Synchronized animations across all arcs ## Center Display The chart includes a center display feature: - Shows total sum of all values - Displays "Total" label - Uses theme colors for consistency - Automatically scales text size - Positioned perfectly in the center ## Legend The automatic legend provides: - Color-coded indicators for each data point - Clear labels with values - Responsive layout - Consistent spacing and typography - Theme-integrated styling <!-- ---------------------------------------------------------------------- --> # Scatter Chart > A customizable scatter plot component with smooth animations and flexible styling for visualizing data relationships. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/charts/scatter-chart - Markdown: https://ui.ahmedbna.com/docs/charts/scatter-chart.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/scatter-chart.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/scatter-chart.json - Install: `npx bna-ui add scatter-chart` - npm dependencies: `react-native-reanimated`, `react-native-svg`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals` - Preview recording: https://demo.ahmedbna.com/0383-scatter-chart-demo.MOV --- **Example:** A scatter plot with smooth animations ```tsx // components/demo/charts/scatter-chart/scatter-chart-demo.tsx import { ScatterPlot } from '@/components/charts/scatter-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const sampleData = [ { x: 10, y: 20, label: 'Point A' }, { x: 25, y: 35, label: 'Point B' }, { x: 40, y: 15, label: 'Point C' }, { x: 55, y: 45, label: 'Point D' }, { x: 70, y: 30, label: 'Point E' }, { x: 85, y: 55, label: 'Point F' }, { x: 30, y: 50, label: 'Point G' }, { x: 65, y: 25, label: 'Point H' }, ]; export function ScatterChartDemo() { return ( <ChartContainer title='Performance vs Experience' description='Scatter plot showing the relationship between years of experience and performance scores' > <ScatterPlot data={sampleData} config={{ height: 300, showGrid: true, showLabels: true, animated: true, duration: 1000, }} /> </ChartContainer> ); } ``` ## Installation ### CLI ```bash npx bna-ui add scatter-chart ``` ### Manual **1.** Install the required dependencies. ```bash npm install react-native-svg react-native-reanimated react-native-worklets ``` **2.** Copy and paste the following code into your project. ```tsx // components/charts/scatter-chart.tsx import { useColor } from '@/hooks/useColor'; import React, { useEffect, useState } from 'react'; import { LayoutChangeEvent, View, ViewStyle } from 'react-native'; import Animated, { SharedValue, useAnimatedProps, useSharedValue, withDelay, withSpring, withTiming, } from 'react-native-reanimated'; import Svg, { Circle, G, Line, Text as SvgText } from 'react-native-svg'; // Animated SVG Components const AnimatedCircle = Animated.createAnimatedComponent(Circle); type AnimatedScatterPointProps = { cx: number; cy: number; fill: string; index: number; animationProgress: SharedValue<number>; }; // Per-item hook must live in its own mounted subcomponent, not in the // parent's .map() body — calling useAnimatedProps per loop iteration // violates Rules of Hooks the moment data.length changes. const AnimatedScatterPoint = React.memo( ({ cx, cy, fill, index, animationProgress }: AnimatedScatterPointProps) => { const pointAnimatedProps = useAnimatedProps(() => ({ opacity: animationProgress.value, r: withDelay(index * 50, withSpring(animationProgress.value * 5)), })); return ( <AnimatedCircle cx={cx} cy={cy} fill={fill} animatedProps={pointAnimatedProps} /> ); } ); interface ChartConfig { width?: number; height?: number; padding?: number; showGrid?: boolean; showLabels?: boolean; animated?: boolean; duration?: number; } export type ChartDataPoint = { x: number; y: number; label?: string; }; type Props = { data: ChartDataPoint[]; config?: ChartConfig; style?: ViewStyle; }; // Scatter Plot Component export const ScatterPlot = ({ data, config = {}, style }: Props) => { const [containerWidth, setContainerWidth] = useState(300); const { height = 200, padding = 20, showGrid = true, showLabels = true, animated = true, duration = 800, } = config; const chartWidth = containerWidth || config.width || 300; const primaryColor = useColor('primary'); const mutedColor = useColor('mutedForeground'); const animationProgress = useSharedValue(0); const handleLayout = (event: LayoutChangeEvent) => { const { width: measuredWidth } = event.nativeEvent.layout; if (measuredWidth > 0) { setContainerWidth(measuredWidth); } }; useEffect(() => { if (animated) { animationProgress.value = withTiming(1, { duration }); } else { animationProgress.value = 1; } }, [data, animated, duration]); if (!data.length) return null; const maxX = Math.max(...data.map((d) => d.x)); const minX = Math.min(...data.map((d) => d.x)); const maxY = Math.max(...data.map((d) => d.y)); const minY = Math.min(...data.map((d) => d.y)); const xRange = maxX - minX || 1; const yRange = maxY - minY || 1; const innerChartWidth = chartWidth - padding * 2; const chartHeight = height - padding * 2; // Convert data to screen coordinates const points = data.map((point) => ({ x: padding + ((point.x - minX) / xRange) * innerChartWidth, y: padding + ((maxY - point.y) / yRange) * chartHeight, })); return ( <View style={[{ width: '100%', height }, style]} onLayout={handleLayout} accessibilityRole='image' accessibilityLabel={`Scatter plot with ${data.length} points, x from ${Math.round(minX)} to ${Math.round(maxX)}, y from ${Math.round(minY)} to ${Math.round(maxY)}`} > <Svg width={chartWidth} height={height}> {/* Grid lines */} {showGrid && ( <G> {[0, 0.25, 0.5, 0.75, 1].map((ratio, index) => ( <G key={`grid-${index}`}> <Line x1={padding} y1={padding + ratio * chartHeight} x2={chartWidth - padding} y2={padding + ratio * chartHeight} stroke={mutedColor} strokeWidth={0.5} opacity={0.3} /> <Line x1={padding + ratio * innerChartWidth} y1={padding} x2={padding + ratio * innerChartWidth} y2={height - padding} stroke={mutedColor} strokeWidth={0.5} opacity={0.3} /> </G> ))} </G> )} {/* Scatter points */} {points.map((point, index) => ( <AnimatedScatterPoint key={`point-${index}`} cx={point.x} cy={point.y} fill={primaryColor} index={index} animationProgress={animationProgress} /> ))} {/* Axis labels */} {showLabels && ( <G> {/* X-axis labels */} {[minX, (minX + maxX) / 2, maxX].map((value, index) => ( <SvgText key={`x-label-${index}`} x={padding + (index * innerChartWidth) / 2} y={height - 5} textAnchor='middle' fontSize={12} fill={mutedColor} > {Math.round(value)} </SvgText> ))} {/* Y-axis labels */} {[maxY, (minY + maxY) / 2, minY].map((value, index) => ( <SvgText key={`y-label-${index}`} x={15} y={padding + (index * chartHeight) / 2} textAnchor='middle' fontSize={12} fill={mutedColor} alignmentBaseline='middle' > {Math.round(value)} </SvgText> ))} </G> )} </Svg> </View> ); }; ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { ScatterPlot } from '@/components/charts/scatter-chart'; ``` ```tsx const data = [ { x: 10, y: 20, label: 'Point A' }, { x: 25, y: 35, label: 'Point B' }, { x: 40, y: 15, label: 'Point C' }, { x: 55, y: 45, label: 'Point D' }, ]; <ScatterPlot data={data} config={{ height: 300, showLabels: true, animated: true, }} />; ``` ## Examples #### Basic Scatter Chart **Example:** A scatter plot with smooth animations ```tsx // components/demo/charts/scatter-chart/scatter-chart-demo.tsx import { ScatterPlot } from '@/components/charts/scatter-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const sampleData = [ { x: 10, y: 20, label: 'Point A' }, { x: 25, y: 35, label: 'Point B' }, { x: 40, y: 15, label: 'Point C' }, { x: 55, y: 45, label: 'Point D' }, { x: 70, y: 30, label: 'Point E' }, { x: 85, y: 55, label: 'Point F' }, { x: 30, y: 50, label: 'Point G' }, { x: 65, y: 25, label: 'Point H' }, ]; export function ScatterChartDemo() { return ( <ChartContainer title='Performance vs Experience' description='Scatter plot showing the relationship between years of experience and performance scores' > <ScatterPlot data={sampleData} config={{ height: 300, showGrid: true, showLabels: true, animated: true, duration: 1000, }} /> </ChartContainer> ); } ``` #### Sample Scatter Chart **Example:** A sample scatter chart with various data points ```tsx // components/demo/charts/scatter-chart/scatter-chart-sample.tsx import { ScatterPlot } from '@/components/charts/scatter-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const sampleData = [ { x: 5, y: 12, label: 'Alpha' }, { x: 15, y: 28, label: 'Beta' }, { x: 35, y: 42, label: 'Gamma' }, { x: 45, y: 18, label: 'Delta' }, { x: 25, y: 65, label: 'Epsilon' }, { x: 55, y: 38, label: 'Zeta' }, { x: 75, y: 52, label: 'Eta' }, { x: 65, y: 78, label: 'Theta' }, { x: 85, y: 25, label: 'Iota' }, { x: 95, y: 88, label: 'Kappa' }, ]; export function ScatterChartSample() { return ( <ChartContainer title='Sample Data Distribution' description='Sample scatter plot with random data points' > <ScatterPlot data={sampleData} config={{ height: 250, showGrid: true, showLabels: true, animated: true, duration: 800, }} /> </ChartContainer> ); } ``` #### Styled Scatter Chart **Example:** A customized scatter chart with custom colors and styling ```tsx // components/demo/charts/scatter-chart/scatter-chart-styled.tsx import { ScatterPlot } from '@/components/charts/scatter-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import { useColor } from '@/hooks/useColor'; import React from 'react'; const sampleData = [ { x: 20, y: 80, label: 'High Performance' }, { x: 35, y: 65, label: 'Good Performance' }, { x: 50, y: 70, label: 'Average Performance' }, { x: 65, y: 45, label: 'Below Average' }, { x: 80, y: 55, label: 'Improving' }, { x: 25, y: 90, label: 'Excellent' }, { x: 75, y: 35, label: 'Needs Work' }, { x: 60, y: 85, label: 'Outstanding' }, ]; export function ScatterChartStyled() { return ( <ChartContainer title='Styled Performance Analysis' description='Customized scatter plot with enhanced styling' > <ScatterPlot data={sampleData} config={{ height: 320, padding: 30, showGrid: true, showLabels: true, animated: true, duration: 1200, }} style={{ backgroundColor: 'rgba(0, 0, 0, 0.02)', borderRadius: 12, borderWidth: 1, borderColor: 'rgba(0, 0, 0, 0.1)', }} /> </ChartContainer> ); } ``` #### Large Scatter Chart **Example:** A scatter chart with large dataset ```tsx // components/demo/charts/scatter-chart/scatter-chart-large.tsx import { ScatterPlot } from '@/components/charts/scatter-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; // Generate a larger dataset for demonstration const generateLargeDataset = () => { const data = []; for (let i = 0; i < 30; i++) { data.push({ x: Math.random() * 100, y: Math.random() * 100, label: `Point ${i + 1}`, }); } return data; }; const largeDataset = generateLargeDataset(); export function ScatterChartLarge() { return ( <ChartContainer title='Large Dataset Visualization' description='Scatter plot with 30 data points showing distribution patterns' > <ScatterPlot data={largeDataset} config={{ height: 400, padding: 25, showGrid: true, showLabels: true, animated: true, duration: 1500, }} /> </ChartContainer> ); } ``` ## API Reference ### ScatterPlot A customizable scatter plot component with smooth animations and flexible styling. Perfect for visualizing relationships between two numerical variables and identifying patterns, trends, or outliers in data. | Prop | Type | Default | Description | | -------- | ------------------ | ------- | --------------------------------------------- | | `data` | `ChartDataPoint[]` | - | Array of data points to display on the chart. | | `config` | `ChartConfig` | `{}` | Configuration object for chart appearance. | | `style` | `ViewStyle` | - | Additional styles to apply to the chart. | ### ChartDataPoint | Prop | Type | Description | | ------- | -------- | ------------------------------------ | | `x` | `number` | The x-coordinate for the data point. | | `y` | `number` | The y-coordinate for the data point. | | `label` | `string` | Optional label for the data point. | ### ChartConfig | Prop | Type | Default | Description | | ------------ | --------- | ------- | ------------------------------------------------- | | `width` | `number` | - | Fixed width of the chart (auto-sizes if omitted). | | `height` | `number` | `200` | Height of the chart. | | `padding` | `number` | `20` | Padding around the chart. | | `showGrid` | `boolean` | `true` | Whether to show grid lines. | | `showLabels` | `boolean` | `true` | Whether to show axis labels. | | `animated` | `boolean` | `true` | Whether to animate the chart on load. | | `duration` | `number` | `800` | Animation duration in milliseconds. | ## Features - **Correlation Analysis**: Visualizes relationships between two numerical variables - **Smooth Animations**: Built-in animations using React Native Reanimated - **Responsive Design**: Automatically adapts to container width - **Grid Lines**: Optional grid lines for better data reading - **Axis Labels**: Shows min, max, and middle values on both axes - **Theme Integration**: Uses theme colors for consistent styling - **Staggered Animation**: Points animate in sequence for visual appeal ## Use Cases Scatter charts are particularly effective for: - **Correlation Analysis**: Identifying relationships between variables - **Trend Identification**: Spotting patterns in data distributions - **Outlier Detection**: Finding unusual data points - **Performance Metrics**: Plotting performance vs. effort, cost vs. benefit - **Scientific Data**: Displaying experimental results or measurements - **Market Analysis**: Price vs. volume, risk vs. return analysis ## Design Considerations The ScatterPlot component is optimized for: - **Data Exploration**: Interactive visualization of data relationships - **Pattern Recognition**: Clear visual representation of data clusters - **Multi-dimensional Analysis**: Two-variable comparison in a single view - **Statistical Analysis**: Visual correlation and regression analysis ## Accessibility The ScatterPlot component includes several accessibility features: - Semantic SVG structure for screen readers - Proper contrast ratios for visual elements - Clear axis labels with numerical values - Grid lines for better data point reference - Supports dynamic text sizing ## Performance The component is optimized for performance: - Uses React Native Reanimated for smooth 60fps animations - Efficient SVG rendering with minimal re-renders - Automatic cleanup of animation values - Responsive layout calculations - Optimized for large datasets ## Styling The component integrates with your theme system: - Uses `primary` color from theme for data points - Uses `mutedForeground` color for grid lines and labels - Consistent with overall design system - Customizable point sizes and colors ## Animation The chart features smooth entry animations: - Points animate from 0 opacity and size to full visibility - Staggered animation creates a ripple effect - Configurable animation duration - Can be disabled for instant rendering - Uses React Native Reanimated for optimal performance ## Mathematical Considerations The scatter plot automatically handles: - **Axis Scaling**: Automatically calculates appropriate scales for both axes - **Data Normalization**: Converts data values to screen coordinates - **Boundary Handling**: Ensures all points fit within the chart area - **Grid Positioning**: Evenly distributes grid lines across the chart ## Data Interpretation Scatter plots help identify: - **Positive Correlation**: Points trending upward from left to right - **Negative Correlation**: Points trending downward from left to right - **No Correlation**: Points scattered without clear pattern - **Outliers**: Points significantly distant from the main cluster - **Clusters**: Groups of points in specific regions <!-- ---------------------------------------------------------------------- --> # Stacked Area Chart > A customizable stacked area chart component with smooth animations and gradient fills for visualizing multiple data series over time. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/charts/stacked-area-chart - Markdown: https://ui.ahmedbna.com/docs/charts/stacked-area-chart.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/stacked-area-chart.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/stacked-area-chart.json - Install: `npx bna-ui add stacked-area-chart` - npm dependencies: `react-native-reanimated`, `react-native-svg`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals` - Preview recording: https://demo.ahmedbna.com/0387-stacked-area-chart-demo.MOV --- **Example:** A stacked area chart with smooth animations and gradient fills ```tsx // components/demo/charts/stacked-area-chart/stacked-area-chart-demo.tsx import { StackedAreaChart } from '@/components/charts/stacked-area-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const sampleData = [ { x: 1, y: [20, 30, 25], label: 'Jan' }, { x: 2, y: [25, 35, 30], label: 'Feb' }, { x: 3, y: [30, 40, 35], label: 'Mar' }, { x: 4, y: [35, 45, 40], label: 'Apr' }, { x: 5, y: [40, 50, 45], label: 'May' }, { x: 6, y: [45, 55, 50], label: 'Jun' }, ]; const categories = ['Product A', 'Product B', 'Product C']; export function StackedAreaChartDemo() { return ( <ChartContainer title='Monthly Revenue by Product' description='Revenue breakdown showing contribution of each product line' > <StackedAreaChart data={sampleData} categories={categories} config={{ height: 300, showLabels: true, showGrid: true, animated: true, duration: 1000, }} /> </ChartContainer> ); } ``` ## Installation ### CLI ```bash npx bna-ui add stacked-area-chart ``` ### Manual **1.** Install the required dependencies. ```bash npm install react-native-svg react-native-reanimated react-native-worklets ``` **2.** Copy and paste the following code into your project. ```tsx // components/charts/stacked-area-chart.tsx import { useColor } from '@/hooks/useColor'; import React, { useEffect, useState } from 'react'; import { LayoutChangeEvent, View, ViewStyle } from 'react-native'; import Animated, { SharedValue, useAnimatedProps, useSharedValue, withTiming, } from 'react-native-reanimated'; import Svg, { Defs, G, Line, LinearGradient, Path, Stop, Text as SvgText, } from 'react-native-svg'; // Animated SVG Components const AnimatedPath = Animated.createAnimatedComponent(Path); type AnimatedAreaProps = { d: string; fill: string; stroke: string; opacityFactor: number; animationProgress: SharedValue<number>; }; // Per-item hook must live in its own mounted subcomponent, not in the // parent's Array.from(...) render loop — calling useAnimatedProps per loop // iteration violates Rules of Hooks the moment seriesCount changes. const AnimatedArea = React.memo( ({ d, fill, stroke, opacityFactor, animationProgress, }: AnimatedAreaProps) => { const areaAnimatedProps = useAnimatedProps(() => ({ opacity: animationProgress.value * opacityFactor, })); return ( <AnimatedPath d={d} fill={fill} stroke={stroke} strokeWidth={1} animatedProps={areaAnimatedProps} /> ); } ); interface ChartConfig { width?: number; height?: number; padding?: number; showGrid?: boolean; showLabels?: boolean; animated?: boolean; duration?: number; } export interface StackedAreaDataPoint { x: number; y: number[]; label?: string; } type Props = { data: StackedAreaDataPoint[]; colors?: string[]; config?: ChartConfig; style?: ViewStyle; categories?: string[]; }; // Utility function to create smooth path const createSmoothPath = (points: { x: number; y: number }[]): string => { if (points.length === 0) return ''; let path = `M${points[0].x},${points[0].y}`; for (let i = 1; i < points.length; i++) { const prevPoint = points[i - 1]; const currentPoint = points[i]; const cpx = (prevPoint.x + currentPoint.x) / 2; const cpy = prevPoint.y; path += ` Q${cpx},${cpy} ${currentPoint.x},${currentPoint.y}`; } return path; }; const createAreaPath = ( topPoints: { x: number; y: number }[], bottomPoints: { x: number; y: number }[] ): string => { if (topPoints.length === 0 || bottomPoints.length === 0) return ''; // Create the top curve const topPath = createSmoothPath(topPoints); // Create the bottom curve (reversed order for proper path closure) const reversedBottomPoints = [...bottomPoints].reverse(); // Start the area path with the top curve let areaPath = topPath; // Add line to the last bottom point areaPath += ` L${reversedBottomPoints[0].x},${reversedBottomPoints[0].y}`; // Add the bottom curve if (reversedBottomPoints.length > 1) { for (let i = 1; i < reversedBottomPoints.length; i++) { const prevPoint = reversedBottomPoints[i - 1]; const currentPoint = reversedBottomPoints[i]; const cpx = (prevPoint.x + currentPoint.x) / 2; const cpy = prevPoint.y; areaPath += ` Q${cpx},${cpy} ${currentPoint.x},${currentPoint.y}`; } } // Close the path areaPath += ' Z'; return areaPath; }; export const StackedAreaChart = ({ data, colors = [], config = {}, style, categories = [], }: Props) => { const [containerWidth, setContainerWidth] = useState(300); const { height = 200, padding = 20, showGrid = true, showLabels = true, animated = true, duration = 1000, } = config; const chartWidth = containerWidth || config.width || 300; const primaryColor = useColor('primary'); const mutedColor = useColor('mutedForeground'); const animationProgress = useSharedValue(0); const handleLayout = (event: LayoutChangeEvent) => { const { width: measuredWidth } = event.nativeEvent.layout; if (measuredWidth > 0) { setContainerWidth(measuredWidth); } }; useEffect(() => { if (animated) { animationProgress.value = withTiming(1, { duration }); } else { animationProgress.value = 1; } }, [data, animated, duration]); if (!data.length) return null; // Calculate stacked totals and max value const stackedData = data.map((point) => { const cumulative = point.y.reduce((acc, val, idx) => { acc.push((acc[acc.length - 1] || 0) + val); return acc; }, [] as number[]); return { ...point, cumulative }; }); const maxValue = Math.max( ...stackedData.map((d) => Math.max(...d.cumulative)) ); const seriesCount = data[0]?.y.length || 0; const innerChartWidth = chartWidth - padding * 2; const chartHeight = height - padding * 2; // Default colors if not provided const defaultColors = [ primaryColor, '#8884d8', '#82ca9d', '#ffc658', '#ff7300', '#00ff00', '#0088fe', ]; // Cycle the default palette via modulo past its length instead of // leaving `undefined` colors for series beyond it. const seriesColors = Array.from({ length: seriesCount }, (_, i) => i < colors.length ? colors[i] : defaultColors[(i - colors.length) % defaultColors.length] ); return ( <View style={[{ width: '100%', height }, style]} onLayout={handleLayout} accessibilityRole='image' accessibilityLabel={`Stacked area chart with ${data.length} data points across ${seriesCount} series, maximum value ${Math.round(maxValue)}`} > <Svg width={chartWidth} height={height}> <Defs> {seriesColors.map((color, index) => ( <LinearGradient key={`gradient-${index}`} id={`areaGradient-${index}`} x1='0%' y1='0%' x2='0%' y2='100%' > <Stop offset='0%' stopColor={color} stopOpacity='0.8' /> <Stop offset='100%' stopColor={color} stopOpacity='0.3' /> </LinearGradient> ))} </Defs> {/* Grid lines */} {showGrid && ( <G> {[0, 0.25, 0.5, 0.75, 1].map((ratio, index) => ( <Line key={`grid-${index}`} x1={padding} y1={padding + ratio * chartHeight} x2={chartWidth - padding} y2={padding + ratio * chartHeight} stroke={mutedColor} strokeWidth={0.5} opacity={0.3} /> ))} </G> )} {/* Stacked areas */} {Array.from({ length: seriesCount }, (_, seriesIndex) => { const topPoints = stackedData.map((point, pointIndex) => ({ x: padding + (pointIndex / (data.length - 1)) * innerChartWidth, y: padding + ((maxValue - point.cumulative[seriesIndex]) / maxValue) * chartHeight, })); // All areas extend from x-axis (y=0) to their cumulative value const bottomPoints = stackedData.map((point, pointIndex) => ({ x: padding + (pointIndex / (data.length - 1)) * innerChartWidth, y: height - padding, // Always extend to x-axis (y=0 in data terms) })); const areaPath = createAreaPath(topPoints, bottomPoints); return ( <AnimatedArea key={`area-${seriesIndex}`} d={areaPath} fill={`url(#areaGradient-${seriesIndex})`} stroke={seriesColors[seriesIndex]} opacityFactor={seriesIndex === 0 ? 1 : 0.7} // Make upper areas slightly transparent animationProgress={animationProgress} /> ); })} {/* Labels */} {showLabels && ( <G> {data.map((point, index) => ( <SvgText key={`label-${index}`} x={padding + (index / (data.length - 1)) * innerChartWidth} y={height - 5} textAnchor='middle' fontSize={12} fill={mutedColor} > {point.label || point.x.toString()} </SvgText> ))} </G> )} {/* Legend */} {categories.length > 0 && ( <G> {categories.map((category, index) => ( <G key={`legend-${index}`}> <Path d={`M${padding + index * 80},${padding - 15} L${ padding + index * 80 + 15 },${padding - 15}`} stroke={seriesColors[index]} strokeWidth={3} /> <SvgText x={padding + index * 80 + 20} y={padding - 10} fontSize={11} fill={mutedColor} > {category} </SvgText> </G> ))} </G> )} </Svg> </View> ); }; ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { StackedAreaChart } from '@/components/charts/stacked-area-chart'; ``` ```tsx const data = [ { x: 1, y: [20, 30, 40], label: 'Jan' }, { x: 2, y: [25, 35, 45], label: 'Feb' }, { x: 3, y: [30, 40, 50], label: 'Mar' }, { x: 4, y: [35, 45, 55], label: 'Apr' }, ]; const categories = ['Series 1', 'Series 2', 'Series 3']; <StackedAreaChart data={data} categories={categories} config={{ height: 300, showLabels: true, animated: true, }} />; ``` ## Examples #### Basic Stacked Area Chart **Example:** A stacked area chart with smooth animations and gradient fills ```tsx // components/demo/charts/stacked-area-chart/stacked-area-chart-demo.tsx import { StackedAreaChart } from '@/components/charts/stacked-area-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const sampleData = [ { x: 1, y: [20, 30, 25], label: 'Jan' }, { x: 2, y: [25, 35, 30], label: 'Feb' }, { x: 3, y: [30, 40, 35], label: 'Mar' }, { x: 4, y: [35, 45, 40], label: 'Apr' }, { x: 5, y: [40, 50, 45], label: 'May' }, { x: 6, y: [45, 55, 50], label: 'Jun' }, ]; const categories = ['Product A', 'Product B', 'Product C']; export function StackedAreaChartDemo() { return ( <ChartContainer title='Monthly Revenue by Product' description='Revenue breakdown showing contribution of each product line' > <StackedAreaChart data={sampleData} categories={categories} config={{ height: 300, showLabels: true, showGrid: true, animated: true, duration: 1000, }} /> </ChartContainer> ); } ``` #### Sample Stacked Area Chart **Example:** A sample stacked area chart with revenue data ```tsx // components/demo/charts/stacked-area-chart/stacked-area-chart-sample.tsx import { StackedAreaChart } from '@/components/charts/stacked-area-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const sampleData = [ { x: 1, y: [45, 55, 35, 25], label: 'Q1' }, { x: 2, y: [50, 60, 40, 30], label: 'Q2' }, { x: 3, y: [55, 65, 45, 35], label: 'Q3' }, { x: 4, y: [60, 70, 50, 40], label: 'Q4' }, { x: 5, y: [65, 75, 55, 45], label: 'Q1' }, { x: 6, y: [70, 80, 60, 50], label: 'Q2' }, ]; const categories = ['Direct Sales', 'Online', 'Retail', 'Partner']; export function StackedAreaChartSample() { return ( <ChartContainer title='Sales Channel Performance' description='Quarterly performance across different sales channels' > <StackedAreaChart data={sampleData} categories={categories} colors={['#8884d8', '#82ca9d', '#ffc658', '#ff7300']} config={{ height: 280, showLabels: true, showGrid: true, animated: true, duration: 1200, }} /> </ChartContainer> ); } ``` #### Styled Stacked Area Chart **Example:** A customized stacked area chart with custom colors and styling ```tsx // components/demo/charts/stacked-area-chart/stacked-area-chart-styled.tsx import { StackedAreaChart } from '@/components/charts/stacked-area-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const sampleData = [ { x: 1, y: [120, 80, 60], label: 'Week 1' }, { x: 2, y: [140, 90, 70], label: 'Week 2' }, { x: 3, y: [160, 100, 80], label: 'Week 3' }, { x: 4, y: [180, 110, 90], label: 'Week 4' }, { x: 5, y: [200, 120, 100], label: 'Week 5' }, { x: 6, y: [220, 130, 110], label: 'Week 6' }, { x: 7, y: [240, 140, 120], label: 'Week 7' }, { x: 8, y: [260, 150, 130], label: 'Week 8' }, ]; const categories = ['Premium', 'Standard', 'Basic']; export function StackedAreaChartStyled() { return ( <ChartContainer title='Subscription Tiers Growth' description='Weekly growth in subscription tiers with custom styling' > <StackedAreaChart data={sampleData} categories={categories} colors={['#6366f1', '#8b5cf6', '#ec4899']} config={{ height: 320, padding: 30, showLabels: true, showGrid: true, animated: true, duration: 1500, }} style={{ backgroundColor: '#f8fafc', borderRadius: 12, padding: 16, }} /> </ChartContainer> ); } ``` #### Large Stacked Area Chart **Example:** A stacked area chart with large dataset ```tsx // components/demo/charts/stacked-area-chart/stacked-area-chart-large.tsx import { StackedAreaChart } from '@/components/charts/stacked-area-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const generateLargeDataset = () => { const data = []; const months = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', ]; for (let i = 0; i < 12; i++) { data.push({ x: i + 1, y: [ Math.floor(Math.random() * 50) + 100, // Desktop Math.floor(Math.random() * 80) + 120, // Mobile Math.floor(Math.random() * 40) + 60, // Tablet Math.floor(Math.random() * 30) + 40, // TV Math.floor(Math.random() * 20) + 20, // Watch ], label: months[i], }); } return data; }; const sampleData = generateLargeDataset(); const categories = ['Desktop', 'Mobile', 'Tablet', 'TV', 'Watch']; export function StackedAreaChartLarge() { return ( <ChartContainer title='Device Usage Analytics' description='Monthly active users across different device types' > <StackedAreaChart data={sampleData} categories={categories} colors={['#0ea5e9', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6']} config={{ height: 350, padding: 25, showLabels: true, showGrid: true, animated: true, duration: 2000, }} /> </ChartContainer> ); } ``` ## API Reference ### StackedAreaChart A customizable stacked area chart component with smooth animations and gradient fills. Perfect for displaying multiple data series over time or categories, showing both individual values and cumulative totals. | Prop | Type | Default | Description | | ------------ | ------------------------ | ------- | --------------------------------------------- | | `data` | `StackedAreaDataPoint[]` | - | Array of data points to display on the chart. | | `colors` | `string[]` | `[]` | Array of colors for each data series. | | `config` | `ChartConfig` | `{}` | Configuration object for chart appearance. | | `style` | `ViewStyle` | - | Additional styles to apply to the chart. | | `categories` | `string[]` | `[]` | Array of category names for the legend. | ### StackedAreaDataPoint | Prop | Type | Description | | ------- | ---------- | -------------------------------------------- | | `x` | `number` | The x-axis value for the data point. | | `y` | `number[]` | Array of y-values for each series at this x. | | `label` | `string` | Optional label for the data point. | ### ChartConfig | Prop | Type | Default | Description | | ------------ | --------- | ------- | ------------------------------------------------- | | `width` | `number` | - | Fixed width of the chart (auto-sizes if omitted). | | `height` | `number` | `200` | Height of the chart. | | `padding` | `number` | `20` | Padding around the chart. | | `showGrid` | `boolean` | `true` | Whether to show grid lines. | | `showLabels` | `boolean` | `true` | Whether to show labels for data points. | | `animated` | `boolean` | `true` | Whether to animate the chart on load. | | `duration` | `number` | `1000` | Animation duration in milliseconds. | ## Features - **Stacked Areas**: Displays multiple data series as stacked areas - **Smooth Curves**: Uses quadratic curves for smooth area transitions - **Gradient Fills**: Beautiful gradient fills for each area - **Smooth Animations**: Built-in animations using React Native Reanimated - **Responsive Design**: Automatically adapts to container width - **Custom Colors**: Support for custom color palettes - **Grid Lines**: Optional grid lines for better readability - **Legend Support**: Built-in legend with category names - **Label Display**: Shows labels for data points on x-axis ## Use Cases Stacked area charts are particularly effective for: - **Time Series Data**: Showing how different categories contribute to a total over time - **Revenue Analysis**: Displaying revenue streams from different sources - **Performance Metrics**: Tracking multiple KPIs simultaneously - **Market Share**: Visualizing market share changes over time - **Resource Allocation**: Showing how resources are distributed across categories - **Survey Results**: Displaying response distributions over time ## Design Considerations The stacked area chart is ideal for: - **Part-to-Whole Relationships**: Showing how individual parts contribute to the whole - **Trend Analysis**: Identifying trends in both individual series and total values - **Comparative Analysis**: Comparing the relative size of different categories - **Cumulative Data**: Displaying cumulative values effectively ## Accessibility The StackedAreaChart component includes several accessibility features: - Semantic SVG structure for screen readers - Proper contrast ratios for visual elements - Text labels for data points and categories - Legend with clear category identification - Grid lines for better value estimation ## Performance The component is optimized for performance: - Uses React Native Reanimated for smooth 60fps animations - Efficient SVG rendering with minimal re-renders - Automatic cleanup of animation values - Optimized path calculations for smooth curves ## Styling The component integrates with your theme system: - Uses `primary` color from theme for default series color - Uses `mutedForeground` color for labels and grid lines - Supports custom colors for each series - Gradient fills with opacity transitions - Responsive layout calculations ## Animation The chart features smooth entry animations: - Areas animate with fade-in effect - Configurable animation duration - Can be disabled for instant rendering - Uses React Native Reanimated for optimal performance - Staggered animations for multiple series ## Data Structure The component expects data in a specific format: ```tsx // Each data point represents a position on the x-axis // with multiple y-values for different series const data = [ { x: 1, y: [10, 20, 30], label: 'Q1' }, { x: 2, y: [15, 25, 35], label: 'Q2' }, { x: 3, y: [12, 22, 32], label: 'Q3' }, { x: 4, y: [18, 28, 38], label: 'Q4' }, ]; ``` ## Color Customization You can customize colors for each series: ```tsx const colors = ['#8884d8', '#82ca9d', '#ffc658', '#ff7300']; <StackedAreaChart data={data} colors={colors} categories={['Series A', 'Series B', 'Series C', 'Series D']} />; ``` ## Grid Configuration Grid lines can be customized: ```tsx <StackedAreaChart data={data} config={{ showGrid: true, // Grid lines are drawn at 0%, 25%, 50%, 75%, and 100% of the chart height }} /> ``` <!-- ---------------------------------------------------------------------- --> # Stacked Bar Chart > A customizable stacked bar chart component with smooth animations, support for both horizontal and vertical layouts, and flexible styling. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/charts/stacked-bar-chart - Markdown: https://ui.ahmedbna.com/docs/charts/stacked-bar-chart.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/stacked-bar-chart.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/stacked-bar-chart.json - Install: `npx bna-ui add stacked-bar-chart` - npm dependencies: `react-native-reanimated`, `react-native-svg`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals` - Preview recording: https://demo.ahmedbna.com/0391-stacked-bar-chart-demo.MOV --- **Example:** A stacked bar chart with smooth animations ```tsx // components/demo/charts/stacked-bar-chart/stacked-bar-chart-demo.tsx import { StackedBarChart } from '@/components/charts/stacked-bar-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const sampleData = [ { label: 'Q1', values: [120, 98, 86] }, { label: 'Q2', values: [140, 110, 95] }, { label: 'Q3', values: [160, 130, 105] }, { label: 'Q4', values: [180, 150, 115] }, ]; const categories = ['Sales', 'Marketing', 'Support']; export function StackedBarChartDemo() { return ( <ChartContainer title='Quarterly Performance' description='Revenue breakdown by department across quarters' > <StackedBarChart data={sampleData} categories={categories} config={{ height: 300, showLabels: true, showGrid: true, animated: true, duration: 1000, }} /> </ChartContainer> ); } ``` ## Installation ### CLI ```bash npx bna-ui add stacked-bar-chart ``` ### Manual **1.** Install the required dependencies. ```bash npm install react-native-svg react-native-reanimated react-native-worklets ``` **2.** Copy and paste the following code into your project. ```tsx // components/charts/stacked-bar-chart.tsx // components/charts/stacked-bar-chart.tsx import { useColor } from '@/hooks/useColor'; import React, { useEffect, useState } from 'react'; import { LayoutChangeEvent, View, ViewStyle } from 'react-native'; import Animated, { SharedValue, useAnimatedProps, useSharedValue, withTiming, } from 'react-native-reanimated'; import Svg, { G, Line, Rect, Text as SvgText } from 'react-native-svg'; // Animated SVG Components const AnimatedRect = Animated.createAnimatedComponent(Rect); type AnimatedHorizontalSegmentProps = { x: number; y: number; barHeight: number; segmentWidth: number; fill: string; animationProgress: SharedValue<number>; }; // Per-item hooks must live in their own mounted subcomponent, not in the // parent's nested item×value .map() body — calling useAnimatedProps per // loop iteration violates Rules of Hooks the moment data changes. Two // subcomponents here (horizontal vs vertical branch) since the two modes // animate different SVG attributes. const AnimatedHorizontalSegment = React.memo( ({ x, y, barHeight, segmentWidth, fill, animationProgress, }: AnimatedHorizontalSegmentProps) => { const segmentAnimatedProps = useAnimatedProps(() => ({ width: animationProgress.value * segmentWidth, })); return ( <AnimatedRect x={x} y={y} height={barHeight} fill={fill} rx={2} animatedProps={segmentAnimatedProps} /> ); } ); type AnimatedVerticalSegmentProps = { x: number; barWidth: number; segmentHeight: number; bottomY: number; fill: string; animationProgress: SharedValue<number>; }; const AnimatedVerticalSegment = React.memo( ({ x, barWidth, segmentHeight, bottomY, fill, animationProgress, }: AnimatedVerticalSegmentProps) => { const segmentAnimatedProps = useAnimatedProps(() => ({ height: animationProgress.value * segmentHeight, y: bottomY - animationProgress.value * segmentHeight, })); return ( <AnimatedRect x={x} width={barWidth} fill={fill} rx={2} animatedProps={segmentAnimatedProps} /> ); } ); interface ChartConfig { width?: number; height?: number; padding?: number; showGrid?: boolean; showLabels?: boolean; animated?: boolean; duration?: number; } export interface StackedBarDataPoint { label: string; values: number[]; } type Props = { data: StackedBarDataPoint[]; colors?: string[]; config?: ChartConfig; style?: ViewStyle; categories?: string[]; horizontal?: boolean; }; export const StackedBarChart = ({ data, colors = [], config = {}, style, categories = [], horizontal = false, }: Props) => { const [containerWidth, setContainerWidth] = useState(300); const { height = 200, padding = 20, showLabels = true, showGrid = true, animated = true, duration = 800, } = config; const chartWidth = containerWidth || config.width || 300; const primaryColor = useColor('primary'); const mutedColor = useColor('mutedForeground'); const animationProgress = useSharedValue(0); const handleLayout = (event: LayoutChangeEvent) => { const { width: measuredWidth } = event.nativeEvent.layout; if (measuredWidth > 0) { setContainerWidth(measuredWidth); } }; useEffect(() => { if (animated) { animationProgress.value = withTiming(1, { duration }); } else { animationProgress.value = 1; } }, [data, animated, duration]); if (!data.length) return null; const maxValue = Math.max( ...data.map((d) => d.values.reduce((sum, val) => sum + val, 0)) ); const seriesCount = data[0]?.values.length || 0; const innerChartWidth = chartWidth - padding * 2; const chartHeight = height - padding * 2; // Default colors if not provided const defaultColors = [ '#8884d8', '#82ca9d', '#ffc658', '#ff7300', '#00ff00', '#0088fe', primaryColor, ]; // Cycle the default palette via modulo past its length instead of // leaving `undefined` colors for series beyond it. const seriesColors = Array.from({ length: seriesCount }, (_, i) => i < colors.length ? colors[i] : defaultColors[(i - colors.length) % defaultColors.length] ); if (horizontal) { // Horizontal stacked bars const barHeight = (chartHeight / data.length) * 0.8; const barSpacing = (chartHeight / data.length) * 0.2; return ( <View style={[{ width: '100%', height }, style]} onLayout={handleLayout} accessibilityRole='image' accessibilityLabel={`Horizontal stacked bar chart with ${data.length} bars across ${seriesCount} series, maximum total ${Math.round(maxValue)}`} > <Svg width={chartWidth} height={height}> {/* Grid lines */} {showGrid && ( <G> {[0, 0.25, 0.5, 0.75, 1].map((ratio, index) => ( <Line key={`grid-${index}`} x1={padding + ratio * innerChartWidth} y1={padding} x2={padding + ratio * innerChartWidth} y2={height - padding} stroke={mutedColor} strokeWidth={0.5} opacity={0.3} /> ))} </G> )} {data.map((item, itemIndex) => { let cumulativeWidth = 0; const y = padding + itemIndex * (barHeight + barSpacing) + barSpacing / 2; return ( <G key={`bar-group-${itemIndex}`}> {item.values.map((value, valueIndex) => { const segmentWidth = (value / maxValue) * innerChartWidth; const x = padding + cumulativeWidth; cumulativeWidth += segmentWidth; return ( <AnimatedHorizontalSegment key={`segment-${itemIndex}-${valueIndex}`} x={x} y={y} barHeight={barHeight} segmentWidth={segmentWidth} fill={seriesColors[valueIndex]} animationProgress={animationProgress} /> ); })} {/* Bar labels */} {showLabels && ( <SvgText x={padding - 10} y={y + barHeight / 2 + 4} textAnchor='end' fontSize={12} fill={mutedColor} > {item.label} </SvgText> )} </G> ); })} {/* Legend */} {categories.length > 0 && ( <G> {categories.map((category, index) => ( <G key={`legend-${index}`}> <Rect x={padding + index * 80} y={height - padding + 10} width={12} height={8} fill={seriesColors[index]} rx={2} /> <SvgText x={padding + index * 80 + 18} y={height - padding + 18} fontSize={11} fill={mutedColor} > {category} </SvgText> </G> ))} </G> )} </Svg> </View> ); } // Vertical stacked bars const barWidth = (innerChartWidth / data.length) * 0.8; const barSpacing = (innerChartWidth / data.length) * 0.2; return ( <View style={[{ width: '100%', height }, style]} onLayout={handleLayout} accessibilityRole='image' accessibilityLabel={`Stacked bar chart with ${data.length} bars across ${seriesCount} series, maximum total ${Math.round(maxValue)}`} > <Svg width={chartWidth} height={height}> {/* Grid lines */} {showGrid && ( <G> {[0, 0.25, 0.5, 0.75, 1].map((ratio, index) => ( <Line key={`grid-${index}`} x1={padding} y1={padding + ratio * chartHeight} x2={chartWidth - padding} y2={padding + ratio * chartHeight} stroke={mutedColor} strokeWidth={0.5} opacity={0.3} /> ))} </G> )} {data.map((item, itemIndex) => { let cumulativeHeight = 0; const x = padding + itemIndex * (barWidth + barSpacing) + barSpacing / 2; const totalValue = item.values.reduce((sum, val) => sum + val, 0); return ( <G key={`bar-group-${itemIndex}`}> {item.values.map((value, valueIndex) => { const segmentHeight = (value / maxValue) * chartHeight; const bottomY = height - padding - cumulativeHeight; cumulativeHeight += segmentHeight; return ( <AnimatedVerticalSegment key={`segment-${itemIndex}-${valueIndex}`} x={x} barWidth={barWidth} segmentHeight={segmentHeight} bottomY={bottomY} fill={seriesColors[valueIndex]} animationProgress={animationProgress} /> ); })} {/* Bar labels */} {showLabels && ( <SvgText x={x + barWidth / 2} y={height - 5} textAnchor='middle' fontSize={12} fill={mutedColor} > {item.label} </SvgText> )} </G> ); })} {/* Legend */} {categories.length > 0 && ( <G> {categories.map((category, index) => ( <G key={`legend-${index}`}> <Rect x={padding + index * 80} y={padding - 25} width={12} height={8} fill={seriesColors[index]} rx={2} /> <SvgText x={padding + index * 80 + 18} y={padding - 17} fontSize={11} fill={mutedColor} > {category} </SvgText> </G> ))} </G> )} </Svg> </View> ); }; ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { StackedBarChart } from '@/components/charts/stacked-bar-chart'; ``` ```tsx const data = [ { label: 'Q1', values: [120, 98, 86] }, { label: 'Q2', values: [140, 110, 95] }, { label: 'Q3', values: [160, 130, 105] }, { label: 'Q4', values: [180, 150, 115] }, ]; const categories = ['Sales', 'Marketing', 'Support']; <StackedBarChart data={data} categories={categories} config={{ height: 300, showLabels: true, animated: true, }} />; ``` ## Examples #### Basic Stacked Bar Chart **Example:** A stacked bar chart with smooth animations ```tsx // components/demo/charts/stacked-bar-chart/stacked-bar-chart-demo.tsx import { StackedBarChart } from '@/components/charts/stacked-bar-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const sampleData = [ { label: 'Q1', values: [120, 98, 86] }, { label: 'Q2', values: [140, 110, 95] }, { label: 'Q3', values: [160, 130, 105] }, { label: 'Q4', values: [180, 150, 115] }, ]; const categories = ['Sales', 'Marketing', 'Support']; export function StackedBarChartDemo() { return ( <ChartContainer title='Quarterly Performance' description='Revenue breakdown by department across quarters' > <StackedBarChart data={sampleData} categories={categories} config={{ height: 300, showLabels: true, showGrid: true, animated: true, duration: 1000, }} /> </ChartContainer> ); } ``` #### Horizontal Stacked Bar Chart **Example:** A horizontal stacked bar chart ```tsx // components/demo/charts/stacked-bar-chart/stacked-bar-chart-horizontal.tsx import { StackedBarChart } from '@/components/charts/stacked-bar-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const sampleData = [ { label: 'Product A', values: [45, 30, 25] }, { label: 'Product B', values: [60, 40, 35] }, { label: 'Product C', values: [55, 35, 30] }, { label: 'Product D', values: [70, 45, 40] }, { label: 'Product E', values: [50, 32, 28] }, ]; const categories = ['Direct Sales', 'Online', 'Retail']; export function StackedBarChartHorizontal() { return ( <ChartContainer title='Product Sales by Channel' description='Sales distribution across different channels' > <StackedBarChart data={sampleData} categories={categories} horizontal={true} config={{ height: 350, showLabels: true, showGrid: true, animated: true, duration: 1200, }} /> </ChartContainer> ); } ``` #### Styled Stacked Bar Chart **Example:** A customized stacked bar chart with custom colors and styling ```tsx // components/demo/charts/stacked-bar-chart/stacked-bar-chart-styled.tsx import { StackedBarChart } from '@/components/charts/stacked-bar-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const sampleData = [ { label: 'Mobile', values: [85, 45, 30, 20] }, { label: 'Desktop', values: [120, 80, 50, 35] }, { label: 'Tablet', values: [65, 35, 25, 15] }, { label: 'Smart TV', values: [40, 20, 15, 10] }, ]; const categories = ['Chrome', 'Safari', 'Firefox', 'Edge']; // Custom colors for different browsers const customColors = [ '#4285F4', // Chrome blue '#FF9500', // Safari orange '#FF6611', // Firefox orange '#0078D4', // Edge blue ]; export function StackedBarChartStyled() { return ( <ChartContainer title='Browser Usage by Device' description='Browser market share across different device types' > <StackedBarChart data={sampleData} categories={categories} colors={customColors} config={{ height: 320, showLabels: true, showGrid: true, animated: true, duration: 1500, padding: 30, }} /> </ChartContainer> ); } ``` #### Large Dataset Stacked Bar Chart **Example:** A stacked bar chart with large dataset ```tsx // components/demo/charts/stacked-bar-chart/stacked-bar-chart-large.tsx import { StackedBarChart } from '@/components/charts/stacked-bar-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const sampleData = [ { label: 'Jan', values: [220, 180, 140, 100, 80] }, { label: 'Feb', values: [240, 190, 150, 110, 85] }, { label: 'Mar', values: [260, 200, 160, 120, 90] }, { label: 'Apr', values: [280, 210, 170, 130, 95] }, { label: 'May', values: [300, 220, 180, 140, 100] }, { label: 'Jun', values: [320, 230, 190, 150, 105] }, { label: 'Jul', values: [340, 240, 200, 160, 110] }, { label: 'Aug', values: [360, 250, 210, 170, 115] }, { label: 'Sep', values: [380, 260, 220, 180, 120] }, { label: 'Oct', values: [400, 270, 230, 190, 125] }, { label: 'Nov', values: [420, 280, 240, 200, 130] }, { label: 'Dec', values: [440, 290, 250, 210, 135] }, ]; const categories = ['Enterprise', 'Professional', 'Standard', 'Basic', 'Free']; export function StackedBarChartLarge() { return ( <ChartContainer title='Annual Subscription Revenue' description='Monthly recurring revenue breakdown by subscription tier' > <StackedBarChart data={sampleData} categories={categories} config={{ height: 400, showLabels: true, showGrid: true, animated: true, duration: 2000, padding: 25, }} /> </ChartContainer> ); } ``` ## API Reference ### StackedBarChart A customizable stacked bar chart component with smooth animations and flexible styling. Perfect for displaying multiple data series stacked on top of each other, supporting both vertical and horizontal orientations. | Prop | Type | Default | Description | | ------------ | ----------------------- | ------- | --------------------------------------------- | | `data` | `StackedBarDataPoint[]` | - | Array of data points to display on the chart. | | `categories` | `string[]` | `[]` | Array of category names for the legend. | | `colors` | `string[]` | `[]` | Custom colors for each data series. | | `config` | `ChartConfig` | `{}` | Configuration object for chart appearance. | | `style` | `ViewStyle` | - | Additional styles to apply to the chart. | | `horizontal` | `boolean` | `false` | Whether to display bars horizontally. | ### StackedBarDataPoint | Prop | Type | Description | | -------- | ---------- | ------------------------------------------ | | `label` | `string` | The label for the data point. | | `values` | `number[]` | Array of values for each stack in the bar. | ### ChartConfig | Prop | Type | Default | Description | | ------------ | --------- | ------- | ------------------------------------------------- | | `width` | `number` | - | Fixed width of the chart (auto-sizes if omitted). | | `height` | `number` | `200` | Height of the chart. | | `padding` | `number` | `20` | Padding around the chart. | | `showLabels` | `boolean` | `true` | Whether to show labels for bars. | | `showGrid` | `boolean` | `true` | Whether to show grid lines. | | `animated` | `boolean` | `true` | Whether to animate the chart on load. | | `duration` | `number` | `800` | Animation duration in milliseconds. | ## Features - **Dual Orientation**: Supports both vertical and horizontal bar layouts - **Smooth Animations**: Built-in animations using React Native Reanimated - **Responsive Design**: Automatically adapts to container width - **Custom Colors**: Support for custom color schemes per data series - **Legend Support**: Built-in legend with category labels - **Grid Lines**: Optional grid lines for better value reading - **Theme Integration**: Uses theme colors for consistent styling - **Rounded Corners**: Aesthetic rounded bar corners ## Use Cases Stacked bar charts are particularly effective for: - **Multi-Category Comparison**: Comparing multiple data series across categories - **Part-to-Whole Analysis**: Showing how individual components contribute to totals - **Time Series Data**: Displaying data evolution over time periods - **Budget Breakdown**: Visualizing spending across different categories and subcategories - **Performance Metrics**: Showing multiple KPIs stacked for comparison - **Survey Results**: Displaying response distributions across multiple questions ## Design Considerations The StackedBarChart component offers flexibility for different use cases: - **Vertical Layout**: Better for time series data and when you have short category labels - **Horizontal Layout**: Ideal for long category names and when you need more space for labels - **Color Coordination**: Uses a default color palette but supports custom colors - **Legend Positioning**: Automatically positions legend based on orientation ## Accessibility The StackedBarChart component includes several accessibility features: - Semantic SVG structure for screen readers - Proper contrast ratios for visual elements - Text labels for both categories and values - Legend support for data series identification - Supports dynamic text sizing - Keyboard navigation support ## Performance The component is optimized for performance: - Uses React Native Reanimated for smooth 60fps animations - Efficient SVG rendering with minimal re-renders - Automatic cleanup of animation values - Responsive layout calculations - Optimized for large datasets ## Styling The component integrates with your theme system: - Uses `primary` color from theme for default bar colors - Uses `mutedForeground` color for labels and text - Supports custom colors per data series - Rounded corners with consistent border radius - Grid lines with subtle opacity ## Animation The chart features smooth entry animations: - Bars animate from 0 size to full size - Stacks animate sequentially for visual appeal - Configurable animation duration - Can be disabled for instant rendering - Uses React Native Reanimated for optimal performance ## Data Structure The component expects data in a specific format: ```tsx // Each data point contains multiple values for stacking const data = [ { label: 'Category A', values: [10, 20, 30] }, // Stack of 3 values { label: 'Category B', values: [15, 25, 35] }, // Stack of 3 values ]; // Categories define what each stack represents const categories = ['Series 1', 'Series 2', 'Series 3']; ``` ## Layout Modes ### Vertical Layout (Default) - Bars extend upward from the bottom - Labels positioned below bars - Legend positioned at the top - Best for timeline data and short labels ### Horizontal Layout - Bars extend rightward from the left - Labels positioned to the left of bars - Legend positioned at the bottom - Best for long category names and mobile screens ## Color Management The component provides flexible color options: - **Default Colors**: Uses a predefined palette with theme integration - **Custom Colors**: Pass an array of colors matching your data series - **Theme Colors**: Automatically uses primary theme color as the base - **Consistent Mapping**: Same color always represents the same data series <!-- ---------------------------------------------------------------------- --> # TreeMap Chart > A customizable treemap chart component with hierarchical data visualization, smooth animations, and flexible styling. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/charts/treemap-chart - Markdown: https://ui.ahmedbna.com/docs/charts/treemap-chart.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/treemap-chart.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/treemap-chart.json - Install: `npx bna-ui add treemap-chart` - npm dependencies: `react-native-reanimated`, `react-native-svg`, `react-native-worklets` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors`, `useColor`, `globals` - Preview recording: https://demo.ahmedbna.com/0395-treemap-chart-demo.MP4 --- **Example:** A treemap chart with smooth animations ```tsx // components/demo/charts/treemap-chart/treemap-chart-demo.tsx import { TreeMapChart } from '@/components/charts/treemap-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const sampleData = [ { label: 'Sales', value: 120 }, { label: 'Marketing', value: 98 }, { label: 'Support', value: 86 }, { label: 'Development', value: 140 }, { label: 'Design', value: 75 }, { label: 'HR', value: 65 }, ]; export function TreeMapChartDemo() { return ( <ChartContainer title='Department Performance' description='Quarterly performance metrics by department' > <TreeMapChart data={sampleData} config={{ height: 300, showLabels: true, animated: true, duration: 1000, }} /> </ChartContainer> ); } ``` ## Installation ### CLI ```bash npx bna-ui add treemap-chart ``` ### Manual **1.** Install the required dependencies. ```bash npm install react-native-svg react-native-reanimated react-native-worklets ``` **2.** Copy and paste the following code into your project. ```tsx // components/charts/treemap-chart.tsx import { useColor } from '@/hooks/useColor'; import React, { useEffect, useMemo, useState } from 'react'; import { LayoutChangeEvent, View, ViewStyle } from 'react-native'; import Animated, { SharedValue, useAnimatedProps, useSharedValue, withTiming, } from 'react-native-reanimated'; import Svg, { G, Rect, Text as SvgText } from 'react-native-svg'; // Animated SVG Components const AnimatedRect = Animated.createAnimatedComponent(Rect); type AnimatedTreemapRectProps = { x: number; y: number; width: number; height: number; fill: string; stroke: string; animationProgress: SharedValue<number>; }; // Per-item hook must live in its own mounted subcomponent, not in the // parent's .map() body — calling useAnimatedProps per loop iteration // violates Rules of Hooks the moment the rectangle count changes. const AnimatedTreemapRect = React.memo( ({ x, y, width, height, fill, stroke, animationProgress, }: AnimatedTreemapRectProps) => { const rectAnimatedProps = useAnimatedProps(() => ({ width: animationProgress.value * width, height: animationProgress.value * height, opacity: animationProgress.value, })); return ( <AnimatedRect x={x} y={y} fill={fill} stroke={stroke} strokeWidth={1} rx={2} animatedProps={rectAnimatedProps} /> ); } ); interface ChartConfig { width?: number; height?: number; padding?: number; showLabels?: boolean; animated?: boolean; duration?: number; } export interface TreeMapDataPoint { label: string; value: number; color?: string; children?: TreeMapDataPoint[]; } interface TreeMapRect { x: number; y: number; width: number; height: number; data: TreeMapDataPoint; depth: number; } type Props = { data: TreeMapDataPoint[]; config?: ChartConfig; style?: ViewStyle; }; // Squarified treemap algorithm const squarify = ( data: TreeMapDataPoint[], x: number, y: number, width: number, height: number, depth: number = 0 ): TreeMapRect[] => { if (data.length === 0) return []; const totalValue = data.reduce((sum, item) => sum + item.value, 0); if (totalValue <= 0) return []; // The squarified-treemap algorithm requires items sorted descending by // value before row placement, so each row is filled largest-first — // skipping this produces poor (long, thin) aspect ratios. const normalizedData = [...data] .sort((a, b) => b.value - a.value) .map((item) => ({ ...item, normalizedValue: (item.value / totalValue) * width * height, })); const layoutRects: TreeMapRect[] = []; let remainingData = [...normalizedData]; let currentX = x; let currentY = y; let remainingWidth = width; let remainingHeight = height; while (remainingData.length > 0) { const vertical = remainingWidth > remainingHeight; const dimension = vertical ? remainingHeight : remainingWidth; // Find the best row/column let bestRow: typeof remainingData = []; let bestRatio = Infinity; for (let i = 1; i <= remainingData.length; i++) { const row = remainingData.slice(0, i); const rowValue = row.reduce((sum, item) => sum + item.normalizedValue, 0); const rowDimension = rowValue / dimension; const worstRatio = Math.max( ...row.map((item) => { const itemDimension = item.normalizedValue / rowDimension; return Math.max( rowDimension / itemDimension, itemDimension / rowDimension ); }) ); if (worstRatio < bestRatio) { bestRatio = worstRatio; bestRow = row; } else { break; } } // Place the row/column const rowValue = bestRow.reduce( (sum, item) => sum + item.normalizedValue, 0 ); const rowDimension = rowValue / dimension; let offset = 0; bestRow.forEach((item) => { const itemDimension = item.normalizedValue / rowDimension; const rectX = vertical ? currentX : currentX + offset; const rectY = vertical ? currentY + offset : currentY; const rectWidth = vertical ? rowDimension : itemDimension; const rectHeight = vertical ? itemDimension : rowDimension; layoutRects.push({ x: rectX, y: rectY, width: rectWidth, height: rectHeight, data: item, depth, }); offset += itemDimension; }); // Update remaining space remainingData = remainingData.slice(bestRow.length); if (vertical) { currentX += rowDimension; remainingWidth -= rowDimension; } else { currentY += rowDimension; remainingHeight -= rowDimension; } } // Items with children are containers, not leaves: subdivide their // allotted rect recursively instead of rendering it directly, so nested // data actually affects the layout instead of being silently ignored. const rects: TreeMapRect[] = []; for (const rect of layoutRects) { if (rect.data.children && rect.data.children.length > 0) { rects.push( ...squarify( rect.data.children, rect.x, rect.y, rect.width, rect.height, depth + 1 ) ); } else { rects.push(rect); } } return rects; }; export const TreeMapChart = ({ data, config = {}, style }: Props) => { const [containerWidth, setContainerWidth] = useState(300); const { height = 200, padding = 10, showLabels = true, animated = true, duration = 800, } = config; // Use measured width or fallback to config width or default const chartWidth = containerWidth || config.width || 300; const backgroundColor = useColor('background'); const animationProgress = useSharedValue(0); const handleLayout = (event: LayoutChangeEvent) => { const { width: measuredWidth } = event.nativeEvent.layout; if (measuredWidth > 0) { setContainerWidth(measuredWidth); } }; useEffect(() => { if (animated) { animationProgress.value = withTiming(1, { duration }); } else { animationProgress.value = 1; } }, [data, animated, duration]); // squarify() is O(n²) per level — memoize rather than recomputing the // full layout (including any recursive children) on every render. const rectangles = useMemo( () => squarify( data, padding, padding, chartWidth - padding * 2, height - padding * 2 ), [data, padding, chartWidth, height] ); if (!data.length) return null; // Generate color palette const colors = [ '#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6', '#06b6d4', '#f97316', '#84cc16', '#ec4899', '#6366f1', ]; const getColor = (index: number, customColor?: string) => { if (customColor) return customColor; return colors[index % colors.length]; }; return ( <View style={[{ width: '100%', height }, style]} onLayout={handleLayout} accessibilityRole='image' accessibilityLabel={`Treemap with ${data.length} top-level items`} > <Svg width={chartWidth} height={height}> {rectangles.map((rect, index) => { const color = getColor(index, rect.data.color); // Determine if text should be light or dark based on background const isLightBackground = color === '#f59e0b' || color === '#84cc16' || color === '#06b6d4'; const textColor = isLightBackground ? '#000000' : '#ffffff'; // Calculate font size based on rectangle size const fontSize = Math.min(rect.width / 8, rect.height / 4, 14); const showText = fontSize > 8 && showLabels; return ( <G key={`rect-${index}`}> <AnimatedTreemapRect x={rect.x} y={rect.y} width={rect.width} height={rect.height} fill={color} stroke={backgroundColor} animationProgress={animationProgress} /> {showText && ( <G> <SvgText x={rect.x + rect.width / 2} y={rect.y + rect.height / 2 - fontSize / 2} textAnchor='middle' fontSize={fontSize} fontWeight='600' fill={textColor} opacity={animationProgress.value} > {rect.data.label} </SvgText> {rect.height > fontSize * 2.5 && ( <SvgText x={rect.x + rect.width / 2} y={rect.y + rect.height / 2 + fontSize / 2} textAnchor='middle' fontSize={fontSize * 0.8} fill={textColor} opacity={0.8} > {rect.data.value} </SvgText> )} </G> )} </G> ); })} </Svg> </View> ); }; ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { TreeMapChart } from '@/components/charts/treemap-chart'; ``` ```tsx const data = [ { label: 'Sales', value: 120 }, { label: 'Marketing', value: 98 }, { label: 'Support', value: 86 }, { label: 'Development', value: 140 }, ]; <TreeMapChart data={data} config={{ height: 300, showLabels: true, animated: true, }} />; ``` ## Examples #### Basic TreeMap Chart **Example:** A treemap chart with smooth animations ```tsx // components/demo/charts/treemap-chart/treemap-chart-demo.tsx import { TreeMapChart } from '@/components/charts/treemap-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const sampleData = [ { label: 'Sales', value: 120 }, { label: 'Marketing', value: 98 }, { label: 'Support', value: 86 }, { label: 'Development', value: 140 }, { label: 'Design', value: 75 }, { label: 'HR', value: 65 }, ]; export function TreeMapChartDemo() { return ( <ChartContainer title='Department Performance' description='Quarterly performance metrics by department' > <TreeMapChart data={sampleData} config={{ height: 300, showLabels: true, animated: true, duration: 1000, }} /> </ChartContainer> ); } ``` #### Sample TreeMap Chart **Example:** A sample treemap chart with various data sizes ```tsx // components/demo/charts/treemap-chart/treemap-chart-sample.tsx import { TreeMapChart } from '@/components/charts/treemap-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const sampleData = [ { label: 'Product A', value: 250 }, { label: 'Product B', value: 180 }, { label: 'Product C', value: 320 }, { label: 'Product D', value: 90 }, { label: 'Product E', value: 150 }, { label: 'Product F', value: 45 }, { label: 'Product G', value: 210 }, { label: 'Product H', value: 75 }, ]; export function TreeMapChartSample() { return ( <ChartContainer title='Product Sales Distribution' description='Revenue breakdown by product category' > <TreeMapChart data={sampleData} config={{ height: 250, showLabels: true, animated: true, duration: 800, }} /> </ChartContainer> ); } ``` #### Styled TreeMap Chart **Example:** A customized treemap chart with custom colors and styling ```tsx // components/demo/charts/treemap-chart/treemap-chart-styled.tsx import { TreeMapChart } from '@/components/charts/treemap-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const styledData = [ { label: 'Mobile', value: 450, color: '#FF6B6B' }, { label: 'Desktop', value: 320, color: '#4ECDC4' }, { label: 'Tablet', value: 180, color: '#45B7D1' }, { label: 'Watch', value: 90, color: '#FFA07A' }, { label: 'TV', value: 150, color: '#98D8C8' }, { label: 'Other', value: 60, color: '#F7DC6F' }, ]; export function TreeMapChartStyled() { return ( <ChartContainer title='Device Usage Analytics' description='User engagement across different device types' > <TreeMapChart data={styledData} config={{ height: 350, padding: 15, showLabels: true, animated: true, duration: 1200, }} /> </ChartContainer> ); } ``` #### Large TreeMap Chart **Example:** A treemap chart with large dataset ```tsx // components/demo/charts/treemap-chart/treemap-chart-large.tsx import { TreeMapChart } from '@/components/charts/treemap-chart'; import { ChartContainer } from '@/components/charts/chart-container'; import React from 'react'; const largeData = [ { label: 'North America', value: 1250 }, { label: 'Europe', value: 980 }, { label: 'Asia Pacific', value: 1450 }, { label: 'South America', value: 320 }, { label: 'Africa', value: 180 }, { label: 'Middle East', value: 240 }, { label: 'Oceania', value: 95 }, { label: 'Central Asia', value: 150 }, { label: 'Caribbean', value: 85 }, { label: 'Eastern Europe', value: 420 }, { label: 'Nordic', value: 280 }, { label: 'Southeast Asia', value: 650 }, { label: 'East Africa', value: 120 }, { label: 'West Africa', value: 200 }, { label: 'Central America', value: 110 }, ]; export function TreeMapChartLarge() { return ( <ChartContainer title='Global Revenue Distribution' description='Revenue breakdown across global regions' > <TreeMapChart data={largeData} config={{ height: 400, padding: 12, showLabels: true, animated: true, duration: 1500, }} /> </ChartContainer> ); } ``` ## API Reference ### TreeMapChart A customizable treemap chart component that uses the squarified treemap algorithm for optimal rectangle aspect ratios. Perfect for displaying hierarchical data with emphasis on proportional relationships between categories. | Prop | Type | Default | Description | | -------- | -------------------- | ------- | --------------------------------------------- | | `data` | `TreeMapDataPoint[]` | - | Array of data points to display on the chart. | | `config` | `ChartConfig` | `{}` | Configuration object for chart appearance. | | `style` | `ViewStyle` | - | Additional styles to apply to the chart. | ### TreeMapDataPoint | Prop | Type | Description | | ---------- | -------------------- | ---------------------------------------------- | | `label` | `string` | The label for the data point. | | `value` | `number` | The value for the data point. | | `color` | `string` | Optional custom color for the rectangle. | | `children` | `TreeMapDataPoint[]` | Optional nested data for hierarchical display. | ### ChartConfig | Prop | Type | Default | Description | | ------------ | --------- | ------- | ------------------------------------------------- | | `width` | `number` | - | Fixed width of the chart (auto-sizes if omitted). | | `height` | `number` | `200` | Height of the chart. | | `padding` | `number` | `10` | Padding around the chart. | | `showLabels` | `boolean` | `true` | Whether to show labels for rectangles. | | `animated` | `boolean` | `true` | Whether to animate the chart on load. | | `duration` | `number` | `800` | Animation duration in milliseconds. | ## Features - **Squarified Algorithm**: Uses the squarified treemap algorithm for optimal rectangle aspect ratios - **Smooth Animations**: Built-in animations using React Native Reanimated - **Responsive Design**: Automatically adapts to container width - **Custom Colors**: Support for individual rectangle colors with automatic color palette - **Label Display**: Shows both category labels and values with smart text sizing - **Theme Integration**: Uses theme colors for consistent styling - **Hierarchical Support**: Prepared for nested data structures - **Smart Text Rendering**: Automatically adjusts text size and color based on rectangle size ## Use Cases TreeMap charts are particularly effective for: - **Proportional Data**: Visualizing data where size represents importance or value - **Portfolio Analysis**: Displaying asset allocation or investment distribution - **Budget Visualization**: Showing spending breakdown across categories - **Market Share**: Representing company or product market share - **File System**: Displaying disk usage or file sizes - **Organizational Data**: Showing department sizes or resource allocation - **Survey Results**: Displaying response distributions with visual impact ## Algorithm The TreeMapChart uses the **squarified treemap algorithm**, which: - Minimizes the aspect ratio of rectangles for better readability - Recursively subdivides the available space - Optimizes for visual clarity by creating more square-like rectangles - Handles varying data sizes efficiently ## Design Considerations The TreeMapChart is designed for: - **Proportional Visualization**: Rectangle size directly represents data values - **Quick Comparison**: Easy to compare relative sizes at a glance - **Space Efficiency**: Makes optimal use of available screen real estate - **Visual Hierarchy**: Larger values are immediately apparent - **Color Coding**: Uses distinct colors to differentiate categories ## Accessibility The TreeMapChart component includes several accessibility features: - Semantic SVG structure for screen readers - Proper contrast ratios with automatic text color adjustment - Text labels for both categories and values - Supports dynamic text sizing based on rectangle size - High contrast borders for visual separation ## Performance The component is optimized for performance: - Uses React Native Reanimated for smooth 60fps animations - Efficient SVG rendering with minimal re-renders - Optimized squarified algorithm implementation - Automatic cleanup of animation values - Responsive layout calculations ## Styling The component integrates with your theme system: - Uses a predefined color palette for consistent styling - Automatic text color adjustment (light/dark) based on background - Uses theme background color for borders - Supports custom colors per data point - Rounded corners with consistent border radius ## Animation The chart features smooth entry animations: - Rectangles animate from 0 size to full size - Opacity fades in during animation - Configurable animation duration - Can be disabled for instant rendering - Uses React Native Reanimated for optimal performance ## Text Rendering Smart text rendering features: - Automatic font size calculation based on rectangle dimensions - Minimum font size threshold to prevent unreadable text - Value display only when sufficient space is available - Proper text centering within rectangles - Automatic color contrast for readability ## Color System The component uses a carefully selected color palette: - 10 distinct colors for visual variety - Automatic color assignment based on data index - Support for custom colors per data point - Automatic text color adjustment for contrast - Consistent color cycling for large datasets <!-- ---------------------------------------------------------------------- --> # Convex > A React Native starter with BNA UI components and a Convex backend — real-time data, with or without Google, Apple, password and email OTP authentication. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/convex - Markdown: https://ui.ahmedbna.com/docs/convex.md --- [Convex](https://convex.dev) is a real-time backend. You write queries and mutations as TypeScript functions, and every client subscribed to a query re-renders when its data changes — no refetching, no cache invalidation. BNA UI ships two Convex scaffolds: one with a backend and no sign-in, one with [`@convex-dev/auth`](https://labs.convex.dev/auth) and the sign-in screens already built. [Expo + Convex](/docs/installation/convex) — A schema and a live query. No authentication. [Expo + Convex + Auth](/docs/installation/convex-auth) — Google, Apple, password and email OTP sign-in, pre-wired. This section covers the schema, authentication, realtime, storage, actions and deployment for both scaffolds, plus provider setup for [Google](/docs/convex/google), [Apple](/docs/convex/apple) and [Resend](/docs/convex/resend). ## The auth starter Built around the same principles as the rest of BNA UI: - **Open Code:** the authentication screens and the Convex functions are copied into your project, not imported from a package. - **Mobile-First:** flows designed for React Native, with tokens in the platform keychain. - **Cross-Platform:** iOS, Android and web. - **Real-Time:** every query is a live subscription. ### Sign-in methods | Method | Provider | Ships with a button | | ---------------- | ----------------------------------------- | -------------------- | | Anonymous | `@convex-dev/auth` `Anonymous` | Yes | | Email + password | `@convex-dev/auth` `Password`, with reset | Yes | | Email OTP | Resend | Yes | | Google | `@auth/core` Google | Yes | | Apple | `@auth/core` Apple | Yes | | GitHub | `@auth/core` GitHub | No — configured only | GitHub is set up in `convex/auth.ts` but the sign-in screen has no button for it. Model one on `components/auth/google.tsx` if you want it. ### What lands in your project ``` app/_layout.tsx ConvexAuthProvider, switching on auth state components/auth/ ├── auth.tsx The sign-in screen: Password / OAuth / OTP tabs ├── password.tsx Sign in, sign up, forgot and reset password ├── email-otp.tsx Passwordless email codes ├── google.tsx Google sign-in button ├── apple.tsx Apple sign-in button └── singout.tsx Sign-out button, used in the settings tab convex/ ├── auth.ts Providers, password rules, redirect allow-list ├── auth.config.ts JWT issuer ├── schema.ts authTables + a users table ├── users.ts User queries and mutations ├── http.ts Mounts the auth HTTP routes ├── resendOTP.ts Email OTP delivery ├── passwordReset.ts Password-reset codes └── resendPasswordOTP.ts ``` The root layout renders a spinner while the session resolves, the sign-in screen when signed out, and your tabs when signed in: ```tsx title="app/_layout.tsx" <ConvexAuthProvider client={convex} storage={secureStorage}> <AuthLoading> <Spinner size='lg' variant='circle' /> </AuthLoading> <Unauthenticated> <Auth /> </Unauthenticated> <Authenticated> <Stack>{/* your app */}</Stack> </Authenticated> </ConvexAuthProvider> ``` `storage` is `expo-secure-store` on iOS and Android — the Keychain and the Keystore respectively — and the platform default on web. ### Reading the signed-in user ```ts title="convex/users.ts" export const get = query({ handler: async (ctx) => { const authId = await getAuthUserId(ctx); if (!authId) { throw new Error('Not authenticated'); } return await ctx.db.get(authId); }, }); ``` ### Password rules `convex/auth.ts` requires at least 8 characters with one digit, one lowercase and one uppercase letter. Edit `validatePasswordRequirements` to change it — it throws, and the message surfaces in the sign-up form. ## Environment | Variable | Where it lives | Set by | Used for | | ------------------------ | ----------------- | --------------------- | ------------------------------------- | | `EXPO_PUBLIC_CONVEX_URL` | `.env.local` | `npx convex dev` | Building the client | | `EXPO_URL` | Convex deployment | `bna-ui convex` | Allow-listing your app scheme | | `SITE_URL` | Convex deployment | `bna-ui convex` | Allow-listing the web redirect target | | `AUTH_RESEND_KEY` | Convex deployment | you | Email OTP and password reset | | `AUTH_GOOGLE_ID/SECRET` | Convex deployment | you | Google sign-in | | `AUTH_APPLE_ID/SECRET` | Convex deployment | you | Apple sign-in | | `CONVEX_SITE_URL` | Convex deployment | Convex, automatically | The JWT issuer | Deployment variables are set with `npx convex env set NAME value` and listed with `npx convex env list`. They are not in `.env.local`, because server-side functions read them. The full walkthrough, including production, is in the [auth installation guide](/docs/installation/convex-auth). ## Guides - [Database and schema](/docs/convex/database) — defineTable, indexes, and how authorization replaces RLS - [Authentication](/docs/convex/auth) — session storage, route guards, OAuth, and the redirect allow-list - [Google](/docs/convex/google) · [Apple](/docs/convex/apple) · [Resend](/docs/convex/resend) - [Storage](/docs/convex/storage) · [Realtime](/docs/convex/realtime) · [Actions](/docs/convex/actions) - [Deployment](/docs/convex/deployment) — EAS, CI, and dev vs. prod deployments - [Troubleshooting](/docs/convex/troubleshooting) — and a Supabase → Convex migration guide ## Learn more - [Convex documentation](https://docs.convex.dev) - [Convex Auth documentation](https://labs.convex.dev/auth) - [Report an issue](https://github.com/ahmedbna/ui/issues) <!-- ---------------------------------------------------------------------- --> # Authentication > How the Convex Auth starter persists a session, guards screens, handles OAuth and email OTP — and why authorization lives in the function, not a policy. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/convex/auth - Markdown: https://ui.ahmedbna.com/docs/convex/auth.md --- The auth starter has four moving parts: a client configured for React Native, `@convex-dev/auth`'s own provider that owns session state, a tri-state guard that decides what renders, and a check inside every function that decides what the database returns. Only the last one is a security boundary. ## The provider ```tsx title="app/_layout.tsx" const secureStorage = { getItem: SecureStore.getItemAsync, setItem: SecureStore.setItemAsync, removeItem: SecureStore.deleteItemAsync, }; const convex = new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!, { unsavedChangesWarning: false, }); <ConvexAuthProvider client={convex} storage={ Platform.OS === 'android' || Platform.OS === 'ios' ? secureStorage : undefined } > ``` `storage` is `expo-secure-store` on iOS and Android — the Keychain and the Keystore respectively. On web, passing `undefined` falls back to the provider's own default (`localStorage`). ### Session storage Supabase's session is a JSON blob several kilobytes wide, which is why that starter splits storage across SecureStore and AsyncStorage (see [Supabase's auth guide](/docs/supabase/auth#session-storage) if you are comparing the two). Convex Auth's session token is compact enough that plain `expo-secure-store` is normally sufficient with no splitting. ## Route guards ```tsx title="app/_layout.tsx" <ConvexAuthProvider client={convex} storage={secureStorage}> <AuthLoading> <Spinner size='lg' variant='circle' /> </AuthLoading> <Unauthenticated> <Auth /> </Unauthenticated> <Authenticated> <Stack>{/* your app */}</Stack> </Authenticated> </ConvexAuthProvider> ``` `AuthLoading`, `Unauthenticated` and `Authenticated` are mutually exclusive — exactly one renders at a time, so there is no frame where the tabs mount before the session has resolved. > They decide what renders on this device. Anyone can run a modified build > that skips straight to `<Stack>`. The check inside each Convex function is > what actually stops one user reading another's data, and it runs on > Convex's servers where the client cannot argue. ## Authorization lives in the function There is no RLS-style policy layer sitting underneath your tables. A query or mutation returns exactly what its handler tells it to — the enforcement is whatever code you write: ```ts title="convex/users.ts" export const get = query({ handler: async (ctx) => { const authId = await getAuthUserId(ctx); if (!authId) { throw new Error('Not authenticated'); } return await ctx.db.get(authId); }, }); ``` > Postgres with RLS enabled and no policy denies every row by default — the > failure mode is "too locked down." A Convex function with no `getAuthUserId` > check simply runs and returns whatever it was coded to return, because there > is no enforcement layer underneath it to fall back on. The failure mode here > is silent data exposure, not an error. See > [database](/docs/convex/database#authorization-lives-in-the-function) for the > same point applied to reads and writes. ## OAuth One code path per provider, not a shared abstraction — copy `google.tsx` for a fourth: ```tsx title="components/auth/google.tsx" const redirectTo = makeRedirectUri(); const { redirect } = await signIn('google', { redirectTo }); if (Platform.OS === 'web') return; const result = await openAuthSessionAsync(redirect!.toString(), redirectTo); if (result.type === 'success') { const code = new URL(result.url).searchParams.get('code')!; await signIn('google', { code }); } ``` `components/auth/apple.tsx` is the same shape with `'apple'` in place of `'google'`. GitHub is configured in `convex/auth.ts` but ships no button — model one on `google.tsx` if you want it. ## The redirect allow-list `signIn`'s `redirect` callback decides which URLs the OAuth flow is allowed to send a session back to: ```ts title="convex/auth.ts" async redirect({ redirectTo }) { const allowed = isAllowedRedirect(redirectTo, { siteUrl: process.env.SITE_URL, expoUrl: process.env.EXPO_URL, }); if (allowed) return redirectTo; throw new Error(`Invalid redirectTo URI ${redirectTo}`); }, ``` ```ts title="convex/lib/redirect.ts" export function isAllowedRedirect( redirectTo: string, { siteUrl, expoUrl }: { siteUrl?: string; expoUrl?: string } ): boolean { const isExpoDevUrl = redirectTo.startsWith('exp://'); const isExpoProdUrl = !!expoUrl && redirectTo.startsWith(expoUrl); const isSiteUrl = !!siteUrl && redirectTo.startsWith(siteUrl); return isExpoDevUrl || isExpoProdUrl || isSiteUrl; } ``` > Supabase's redirect allow-list is a setting in **Authentication → URL > Configuration**. Convex's is these three checks against `EXPO_URL` and > `SITE_URL`, set with `npx convex env set`. Change your app's `scheme` in > `app.json` and you must update `EXPO_URL` too, or every OAuth sign-in starts > failing with no client-visible reason why. ## Password auth ```ts title="convex/auth.ts" Password({ profile(params) { return { name: params.name as string, email: params.email as string, gender: params.gender as string, }; }, reset: ResendOTPPasswordReset, validatePasswordRequirements(password) { if (!password || password.length < 8) { throw new Error('Password must be at least 8 characters long'); } if (!/\d/.test(password)) { throw new Error('Password must contain at least one number'); } if (!/[a-z]/.test(password)) { throw new Error('Password must contain at least one lowercase letter'); } if (!/[A-Z]/.test(password)) { throw new Error('Password must contain at least one uppercase letter'); } }, }), ``` `components/auth/password.tsx` drives four flows through the same `signIn` call, distinguished by `flow`: | UI step | `signIn('password', { flow: ... })` | | --------------- | ------------------------------------ | | Sign in | `'signIn'` | | Sign up | `'signUp'` | | Forgot password | `'reset'` — sends the reset code | | Reset password | `'reset-verification'` — consumes it | Edit `validatePasswordRequirements` to change the rule — it throws, and the message surfaces directly in the sign-up form. ## Email OTP and password reset Both the OTP tab and the reset flow send mail through [Resend](https://resend.com): ```ts title="convex/resendOTP.ts" export const ResendOTP = Email({ id: 'resend-otp', apiKey: process.env.AUTH_RESEND_KEY, maxAge: 60 * 15, async sendVerificationRequest({ identifier: email, provider, token }) { // …sends `token` to `email` via the Resend API }, }); ``` Full setup, including the from-address you need to change before shipping: [Resend](/docs/convex/resend). ## Anonymous sign-in `Anonymous` is first in the `providers` array and needs no configuration — `components/auth/auth.tsx`'s **Login anonymously** button just calls `signIn('anonymous')`. ## Sign out ```tsx title="components/auth/singout.tsx" const { signOut } = useAuthActions(); const handleSignOut = async () => { await signOut(); router.dismissAll(); }; ``` `dismissAll()` clears any modals or nested stacks left mounted from the authenticated session before the `Unauthenticated` branch takes over. ## Next - [Database and schema](/docs/convex/database) - [Google](/docs/convex/google) · [Apple](/docs/convex/apple) · [Resend](/docs/convex/resend) - [Troubleshooting](/docs/convex/troubleshooting) <!-- ---------------------------------------------------------------------- --> # Database and schema > The schema both Convex starters ship, defineSchema and defineTable, indexes, validators, and how authorization replaces row level security. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/convex/database - Markdown: https://ui.ahmedbna.com/docs/convex/database.md --- Your schema lives in `convex/schema.ts` as plain TypeScript, validated by `v.*` at both write time and the type level. There is no separate migration file and no ORM — `_generated/` regenerates from `schema.ts` automatically every time `npx convex dev` sees it change. ## The workflow **1.** Edit convex/schema.ts Add a field, a table, or an index. **2.** Save `npx convex dev` picks up the change, pushes it to your deployment, and regenerates `convex/_generated/` — there is no separate `push` or `generate` command to remember. **3.** Commit the schema and the regenerated types together `_generated/` is checked in so a fresh clone and CI both typecheck with no live deployment. See [deployment](/docs/convex/deployment) for what CI does and does not verify about it. > Adding a required field to a table that already has documents fails until > every existing row has one. Add it as `v.optional(...)`, backfill with a > mutation, then tighten it to required in a later deploy — the same shape as > Supabase's "add nullable, backfill, then add the constraint," just enforced by > the schema instead of a lock on an `alter table`. ## Defining tables ```ts title="convex/schema.ts" export default defineSchema({ tasks: defineTable({ text: v.string(), isCompleted: v.boolean(), }), }); ``` The auth starter's schema spreads in `@convex-dev/auth`'s own tables and adds one more: ```ts title="convex/schema.ts" export default defineSchema({ ...authTables, users: defineTable({ email: v.optional(v.string()), phone: v.optional(v.string()), name: v.optional(v.string()), image: v.optional(v.union(v.string(), v.null())), isAnonymous: v.optional(v.boolean()), githubId: v.optional(v.number()), // … }) .index('email', ['email']) .index('phone', ['phone']), }); ``` `v.optional(...)` is why an anonymous or Apple sign-up — which may supply no email at all — does not fail schema validation. `v.union(v.string(), v.null())` is how a field can legitimately hold `null` rather than being absent, which `v.optional` alone does not allow. ## Indexes ```ts title="convex/schema.ts" users: defineTable({ /* … */ }).index('email', ['email']), ``` ```ts title="convex/users.ts" const user = await ctx.db .query('users') .withIndex('email', (q) => q.eq('email', args.email)) .unique(); ``` A query without a matching index scans every document in the table. There is no RLS policy to make this the "common case" the way Supabase's is — you add an index because you wrote a `withIndex` query that needs one, not because a policy filters on the column. ## Queries and mutations ```ts title="convex/tasks.ts" export const list = query({ handler: async (ctx) => { return await ctx.db.query('tasks').order('desc').take(50); }, }); export const add = mutation({ args: { text: v.string() }, handler: async (ctx, args) => { return await ctx.db.insert('tasks', { text: args.text, isCompleted: false, }); }, }); ``` Queries are read-only and reactive — every subscribed `useQuery` re-renders when the data they read changes. Mutations are transactional writes. Anything that calls a third-party API or needs a secret is neither — see [actions](/docs/convex/actions). ## Authorization lives in the function Convex has nothing analogous to `enable row level security` — a query returns exactly what its handler returns, for any caller who can invoke it. The auth starter's user-scoped queries all start the same way: ```ts title="convex/users.ts" export const get = query({ handler: async (ctx) => { const authId = await getAuthUserId(ctx); if (!authId) { throw new Error('Not authenticated'); } return await ctx.db.get(authId); }, }); ``` | Concern | How Convex handles it | | ---------------------------- | -------------------------------------------------------- | | "Only the owner can read X" | Check `getAuthUserId(ctx)` against the row's owner field | | "Only the owner can write X" | Same check, inside the mutation, before the `db.patch` | | "Public read, scoped write" | A query with no auth check; a mutation with one | > A Postgres table with RLS enabled and no policy denies every row — the failure > mode is "too locked down." A Convex function with no `getAuthUserId` check > simply runs and returns whatever it was coded to return, because there is no > enforcement layer underneath it. Read every query and mutation you write as if > the RLS backstop does not exist, because it does not. ## The no-auth starter is different `convex/tasks.ts` in the plain `convex` overlay has no auth check at all — correct for a public demo, wrong the moment you store anything real: ```ts title="convex/tasks.ts" export const list = query({ handler: async (ctx) => { return await ctx.db.query('tasks').order('desc').take(50); }, }); ``` Anyone who can reach your deployment's URL can call this exactly as written, from `curl`, forever. Add an owner check (or move to the auth starter) before putting anything private behind it. ## Realtime is automatic There is no publication to alter and no replica identity to set. Every `useQuery` is a live subscription the moment it runs. See [realtime](/docs/convex/realtime). ## Next - [Realtime](/docs/convex/realtime) - [Storage](/docs/convex/storage) · [Actions](/docs/convex/actions) - [Convex schema documentation](https://docs.convex.dev/database/schemas) <!-- ---------------------------------------------------------------------- --> # Realtime > Why useQuery needs no subscription setup, adding optimistic updates yourself, reconnect behaviour, offline, and debugging a query that never resolves. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/convex/realtime - Markdown: https://ui.ahmedbna.com/docs/convex/realtime.md --- Every Convex query is a live subscription. `useQuery` re-renders whenever data it read changes on the server — no publication to enable, no channel to open, no `removeChannel` to remember on unmount. ## Subscribing needs nothing extra ```tsx title="app/(tabs)/(home)/index.tsx" const tasks = useQuery(api.tasks.list); if (tasks === undefined) return <Spinner size='lg' variant='circle' />; ``` `tasks` is `undefined` while the first result is in flight, then stays live for the rest of the component's lifetime. Every client with this hook mounted re-renders when any mutation touches a document the query read. > Supabase's `postgres_changes` subscription needs an explicit `removeChannel` > on unmount, or a screen that mounts twice double-applies every event. > `useQuery` unsubscribes itself when the component unmounts — there is no > channel object to hold onto or forget. ## Optimistic updates Neither starter uses these — `toggle` and `remove` are plain, awaited mutations, and the UI waits for the round trip. Convex supports optimistic local updates if you want the mutation to feel instant: ```ts title="Adding this yourself" const toggle = useMutation(api.tasks.toggle).withOptimisticUpdate( (localStore, args) => { const tasks = localStore.getQuery(api.tasks.list); if (tasks === undefined) return; localStore.setQuery( api.tasks.list, {}, tasks.map((t) => t._id === args.id ? { ...t, isCompleted: !t.isCompleted } : t ) ); } ); ``` The local patch is discarded automatically once the server's real update arrives over the subscription — there is no manual reconciliation step like Supabase's dedupe-by-id, because Convex already knows which local state belongs to which in-flight mutation. ## Reconnecting `ConvexReactClient` re-establishes the WebSocket itself and resyncs every active subscription to a consistent state on reconnect. There is nothing in either starter watching connection status, and nothing you need to write for the common case — this is unlike Supabase, where a dropped socket needs an explicit refetch on the next `SUBSCRIBED` event. ## Offline Same honesty as Supabase's starters: there is no built-in offline queue. - Reads render from the last state the client had, so a backgrounded app still shows something. - A mutation made with no connection rejects — it does not silently queue. - Reconnecting resyncs every active query automatically. Queued mutations and conflict resolution are a sync layer you would add on top, not something either starter picks for you. ## Debugging a stuck subscription **1.** Confirm the deployment URL `EXPO_PUBLIC_CONVEX_URL` in `.env.local` has to match the deployment `npx convex dev` is running against — a stale value from switching projects is the most common cause of "nothing ever loads." **2.** Confirm npx convex dev is actually running Without it, function changes never reach your deployment, and the client has nothing to subscribe to. **3.** Check the dashboard's Logs An uncaught error inside a query handler does not surface to the client the way you would expect. > If a query handler throws, the client does not get an error object back — > `useQuery` simply never resolves past `undefined`. There is no client-visible > signal that anything is wrong; the only place it shows up is the deployment's > function logs. ## Next - [Database and schema](/docs/convex/database) - [Storage](/docs/convex/storage) - [Troubleshooting](/docs/convex/troubleshooting) <!-- ---------------------------------------------------------------------- --> # File Storage > Upload files from Expo to Convex's built-in file storage — generating an upload URL, reading files back, authorization, and deletion. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/convex/storage - Markdown: https://ui.ahmedbna.com/docs/convex/storage.md --- > Neither the plain nor the auth starter uses Convex file storage — there is no > avatar upload or file-picker screen to copy from. This page is a reference for > adding it yourself, in the same style as the rest of your schema and > functions. ## Generating an upload URL Convex file storage is a two-step upload: a mutation hands the client a short-lived URL, and the client `POST`s bytes directly to it. ```ts title="convex/files.ts" export const generateUploadUrl = mutation({ handler: async (ctx) => { return await ctx.storage.generateUploadUrl(); }, }); ``` ## Uploading from Expo ```ts const uploadUrl = await generateUploadUrl(); const asset = await ImagePicker.launchImageLibraryAsync(); const body = await fetch(asset.assets[0].uri).then((res) => res.blob()); const result = await fetch(uploadUrl, { method: 'POST', headers: { 'Content-Type': asset.assets[0].mimeType ?? 'image/jpeg' }, body, }); const { storageId } = await result.json(); ``` `storageId` is what you save — patch it onto a document in a second mutation, the same as you would any other field: ```ts title="convex/users.ts" export const setAvatar = mutation({ args: { storageId: v.id('_storage') }, handler: async (ctx, args) => { const userId = await getAuthUserId(ctx); if (!userId) throw new Error('Not authenticated'); await ctx.db.patch(userId, { avatarStorageId: args.storageId }); }, }); ``` ## Reading a file back ```ts title="convex/users.ts" export const getAvatarUrl = query({ handler: async (ctx) => { const userId = await getAuthUserId(ctx); if (!userId) throw new Error('Not authenticated'); const user = await ctx.db.get(userId); if (!user?.avatarStorageId) return null; return await ctx.storage.getUrl(user.avatarStorageId); }, }); ``` `getUrl` returns a URL good for the lifetime of the file, not a signed, expiring link — there is no separate "public bucket" versus "signed URL" distinction the way Supabase Storage has. Access control is whatever your query checks before calling it. ## Authorization is still a function check There is no bucket-level policy language. The same rule from [database](/docs/convex/database#authorization-lives-in-the-function) applies: whoever can call `getAvatarUrl` gets whatever it returns, so the check has to be in the handler, not configured somewhere else. ## Deleting files ```ts title="convex/files.ts" export const remove = mutation({ args: { storageId: v.id('_storage') }, handler: async (ctx, args) => { await ctx.storage.delete(args.storageId); }, }); ``` Deleting a document that references a file does not delete the file — `ctx.storage.delete` has to be called explicitly, the same as Supabase Storage objects not cascading from a deleted row. Anything you store per user needs the same cleanup on account deletion. ## Larger files The upload URL approach above handles anything a mobile client can hold in memory to `fetch`. Past tens of megabytes, stream the upload in chunks rather than buffering the whole blob — the two-step URL pattern stays the same. ## Next - [Actions](/docs/convex/actions) - [Database and schema](/docs/convex/database) - [Convex file storage documentation](https://docs.convex.dev/file-storage) <!-- ---------------------------------------------------------------------- --> # Actions > When to reach for a Convex action instead of a query or mutation, the Node runtime, calling third-party APIs, and scheduling. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/convex/actions - Markdown: https://ui.ahmedbna.com/docs/convex/actions.md --- > Neither starter defines an `action`, though the auth starter comes close — > `convex/resendOTP.ts` calls the Resend API from inside a provider callback. > This page is a reference for the general case. Queries and mutations are deterministic and transactional — no `fetch`, no `Math.random()`, no Node built-ins. Anything that calls a third-party API, needs true randomness, or needs a Node package belongs in an action instead. ## Defining an action ```ts title="convex/tasks.ts" export const summarize = action({ args: { taskId: v.id('tasks') }, handler: async (ctx, args) => { const response = await fetch('https://api.example.com/summarize', { method: 'POST', headers: { Authorization: `Bearer ${process.env.SUMMARY_API_KEY}` }, body: JSON.stringify({ taskId: args.taskId }), }); return await response.json(); }, }); ``` Actions run in Convex's default V8-isolate runtime by default — the same environment queries and mutations use, with `fetch` available but no Node built-ins (`fs`, `crypto`'s Node API, etc.). ## Reach for `'use node'` only when you need it ```ts title="convex/heavyWork.ts" 'use node'; import sharp from 'sharp'; // … ``` Adding `'use node'` at the top of a file switches every action in it to a full Node.js runtime, at the cost of a colder start. `convex/resendOTP.ts` in the auth starter is the useful contrast: it calls the Resend API with `fetch` and generates its OTP with Web Crypto's `crypto.getRandomValues`, both available in the default runtime, so it has no `'use node'` directive at all. Reach for it only when a package assumes Node — an image-processing library, a PDF generator — not by default. ## Actions cannot touch the database directly ```ts export const doWork = action({ handler: async (ctx) => { const task = await ctx.runQuery(internal.tasks.getInternal, {/* … */}); // …call a third-party API with `task`… await ctx.runMutation(internal.tasks.markDone, { id: task._id }); }, }); ``` `ctx.runQuery` / `ctx.runMutation` are the only way an action reads or writes — there is no `ctx.db` inside one. `internal.*` (as opposed to `api.*`) marks a query or mutation as callable only from other Convex functions, not from a client. ## Calling an action from Expo ```ts const summarize = useAction(api.tasks.summarize); await summarize({ taskId }); ``` Same shape as `useMutation`, but without the transactional guarantees — an action that partially fails does not roll back what it already did. ## Secrets ```bash npx convex env set SUMMARY_API_KEY sk_live_... ``` Same mechanism already used for `AUTH_RESEND_KEY` and the OAuth provider secrets — set once per deployment, read with `process.env` inside the function, never shipped to the client. ## Scheduling ```ts await ctx.scheduler.runAfter(0, internal.tasks.summarize, { taskId }); ``` For recurring work, `convex/crons.ts` exports a `cronJobs()` registry — Convex's equivalent of Supabase's `pg_cron`, running inside your deployment rather than the database. ## Next - [Storage](/docs/convex/storage) - [Database and schema](/docs/convex/database) - [Convex actions documentation](https://docs.convex.dev/functions/actions) <!-- ---------------------------------------------------------------------- --> # Google Sign-In > Step-by-step guide to configuring Google as an OAuth provider for the Convex Auth starter — the Cloud project, the consent screen, the OAuth client, the .site callback URL, and what to do when the browser comes back to nothing. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/convex/google - Markdown: https://ui.ahmedbna.com/docs/convex/google.md --- The starter's Google button works as soon as the provider is configured on your Convex deployment. No code changes — it shares the browser flow with Apple. ## Before you start - A Google account. Any account works; you do not need a paid plan or a verified domain to sign in during development. - A Convex deployment, so you have an HTTP Actions URL to point Google at. `npx convex dev --once` is enough — see the [auth installation guide](/docs/installation/convex-auth). ## Create the OAuth client **1.** Create or select a Google Cloud project 1. Open the [Google Cloud console](https://console.cloud.google.com) and click **Select a project** in the top bar. 2. Click **New project** in the dialog. 3. Give the project a name. This one is internal — your users never see it. 4. Click **Create** and wait for it to finish provisioning. 5. Click **Select a project** again. 6. Pick the project you just created. ![Google Cloud console welcome page with the Select a project button highlighted](https://demo.ahmedbna.com/0409-google-oauth-setup-01.png) ![Select a project dialog with the New project button highlighted](https://demo.ahmedbna.com/0410-google-oauth-setup-02.png) ![New Project form with the project name filled in and the Create button highlighted](https://demo.ahmedbna.com/0411-google-oauth-setup-03.png) ![Back on the welcome page, opening the project picker again](https://demo.ahmedbna.com/0412-google-oauth-setup-04.png) ![Selecting the newly created project from the Recent tab](https://demo.ahmedbna.com/0413-google-oauth-setup-05.png) **2.** Open the Google Auth Platform 7. From **Quick access**, click **APIs & Services**. 8. In the left navigation, click **OAuth consent screen**. 9. Click **Get started**. ![Project dashboard with the APIs and Services quick-access card highlighted](https://demo.ahmedbna.com/0414-google-oauth-setup-06.png) ![APIs and Services with OAuth consent screen highlighted in the left navigation](https://demo.ahmedbna.com/0415-google-oauth-setup-07.png) ![Google Auth Platform not configured yet, with the Get started button highlighted](https://demo.ahmedbna.com/0416-google-oauth-setup-08.png) **3.** Fill in the app information 10. **App name** — this one _is_ shown to users on the consent screen. 11. **User support email** — pick one from the dropdown. 12. Click **Next**. ![Project configuration step 1, App Information, with app name and support email](https://demo.ahmedbna.com/0417-google-oauth-setup-09.png) **4.** Choose the audience 13. Pick **External** unless this is a Workspace-only app. **Internal** is limited to accounts in your organization. 14. Click **Next**. ![Project configuration step 2, Audience, with External selected](https://demo.ahmedbna.com/0418-google-oauth-setup-10.png) > While the app is in **Testing**, only accounts you list under **Audience → > Test users** can sign in — everyone else gets "access blocked." Add your own > account there before you try the flow, and publish the app before you launch. **5.** Add contact information and create the configuration 15. Enter one or more email addresses. Google uses these to notify you about project changes. 16. Click **Next**. 17. Check **I agree to the Google API Services: User Data Policy**. 18. Click **Continue**. 19. Click **Create**. ![Project configuration step 3, Contact Information, with an email address entered](https://demo.ahmedbna.com/0419-google-oauth-setup-11.png) ![Project configuration step 4, Finish, with the policy agreement checked and Create highlighted](https://demo.ahmedbna.com/0420-google-oauth-setup-12.png) **6.** Create the OAuth client 20. In the left navigation, click **Clients**. 21. Click **Create client**. 22. Set **Application type** to **Web application**. Web, not Android or iOS: the browser flow authenticates against Convex's HTTP Actions endpoint, which is a web endpoint. 23. Give the client a name. This one is only used to identify it in the console. ![OAuth Overview after configuration, with Clients highlighted in the left navigation](https://demo.ahmedbna.com/0421-google-oauth-setup-13.png) ![Empty Clients list with the Create client button highlighted](https://demo.ahmedbna.com/0422-google-oauth-setup-14.png) ![Create OAuth client ID form with Web application selected and a name entered](https://demo.ahmedbna.com/0423-google-oauth-setup-15.png) **7.** Find your Convex HTTP Actions URL Leave that tab open and switch to the Convex dashboard. 24. Open **Settings**. 25. Open **URL & Deploy Key**. 26. Copy the **HTTP Actions URL**. It ends in `.site`. ![Convex deployment settings showing the HTTP Actions URL ending in .site](https://demo.ahmedbna.com/0424-google-oauth-setup-16.png) > Every Convex deployment has two URLs: `*.convex.cloud` for the client SDK, and > `*.convex.site` for HTTP Actions — which is what serves the OAuth callback. > Pasting the `.cloud` URL into Google's redirect URI is the single most common > cause of `redirect_uri_mismatch` here. **8.** Add the authorized redirect URI Back in the Google console: 27. Under **Authorized redirect URIs**, click **Add URI**. 28. Paste the HTTP Actions URL and append `/api/auth/callback/google`. 29. Click **Create**. ``` {HTTP_ACTIONS_URL}/api/auth/callback/google ``` For example, if your HTTP Actions URL is `https://fast-horse-123.convex.site`: ``` https://fast-horse-123.convex.site/api/auth/callback/google ``` **Authorized JavaScript origins** can stay empty. The starter's flow never calls Google from a browser origin you control — it hands off to Convex's `.site` endpoint, which is already covered by the redirect URI. ![Authorized redirect URIs with the Convex callback URL pasted in](https://demo.ahmedbna.com/0425-google-oauth-setup-17.png) **9.** Copy the client ID and secret 30. Copy the **Client ID** from the confirmation dialog, then click **OK**. 31. Back on the **Clients** list, click the client you just made. 32. Under **Client secrets**, use the existing secret or click **Add secret**. 33. Copy the **Client secret**. Google no longer lets you view a secret after you leave the page, so copy it now — if you lose it, add a new one and delete the old. ![OAuth client created dialog showing the Client ID](https://demo.ahmedbna.com/0426-google-oauth-setup-18.png) ![OAuth 2.0 Client IDs list with the new client listed](https://demo.ahmedbna.com/0427-google-oauth-setup-19.png) ![Client detail page with the Client secrets panel and Add secret button](https://demo.ahmedbna.com/0428-google-oauth-setup-20.png) **10.** Set the environment variables From your project directory: ```bash npx convex env set AUTH_GOOGLE_ID your_client_id npx convex env set AUTH_GOOGLE_SECRET your_client_secret ``` 34. Or paste them into the Convex dashboard under **Settings → Environment Variables** and click **Save All**. ![Convex Environment Variables panel with AUTH\_GOOGLE\_ID and AUTH\_GOOGLE\_SECRET](https://demo.ahmedbna.com/0429-google-oauth-setup-21.png) ## The deep-link scheme One more variable, and the one people forget: `EXPO_URL` is your app's deep-link scheme, and it is what lets Convex redirect back _into the app_ after Google hands the browser back. It has to match the `scheme` in your `app.json`. ```bash npx convex env set EXPO_URL my-app:// ``` ![Convex Environment Variables panel with EXPO\_URL set to the app scheme](https://demo.ahmedbna.com/0430-google-oauth-setup-22.png) Without it the browser sheet closes and nothing happens — see [the redirect allow-list](/docs/convex/auth#the-redirect-allow-list). ## Test it ```bash npx expo start ``` Tap **Login with Google**. A browser sheet opens, you pick an account, and it closes; the session lands and you're routed into the app. ## What the starter does ```tsx title="components/auth/google.tsx" const redirectTo = makeRedirectUri(); const { redirect } = await signIn('google', { redirectTo }); if (Platform.OS === 'web') return; const result = await openAuthSessionAsync(redirect!.toString(), redirectTo); if (result.type === 'success') { const code = new URL(result.url).searchParams.get('code')!; await signIn('google', { code }); } ``` `signIn('google', { redirectTo })` kicks off the flow and hands back the URL to open; the second `signIn` call, with the code Google returned, is what actually completes it. See [authentication](/docs/convex/auth#oauth) for how Apple reuses the same shape. ## When it does not work | What you see | Usually | | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `redirect_uri_mismatch` | The `.cloud` URL was used instead of `.site`, or the path has a typo | | Browser closes, nothing happens | `EXPO_URL`/`SITE_URL` don't cover the redirect Convex sent back — see [the redirect allow-list](/docs/convex/auth#the-redirect-allow-list) | | "Access blocked: app not verified" | Consent screen still in Testing and this account isn't a test user | | Works after `npx convex env set`, still fails | The deployment needs a moment to pick up new env vars — retry once | | Nothing changed after editing the OAuth client | Google warns it can take 5 minutes to a few hours for client settings to propagate | ## Next - [Apple](/docs/convex/apple) · [Resend](/docs/convex/resend) - [Authentication](/docs/convex/auth) - [Convex Auth Google provider docs](https://labs.convex.dev/auth/config/oauth/google) <!-- ---------------------------------------------------------------------- --> # Apple Sign-In > Step-by-step guide to configuring Apple as an OAuth provider for the Convex Auth starter — the App ID, the Services ID, the signing key, and the JWT client secret you have to generate and rotate. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/convex/apple - Markdown: https://ui.ahmedbna.com/docs/convex/apple.md --- > Apple requires a deployed, HTTPS callback — your Convex deployment's `.site` > URL already qualifies, so there is no local-testing workaround to reach for > here the way there might be with other providers. ## Before you start - A paid [Apple Developer](https://developer.apple.com) account. Sign in with Apple is not available on a free account. - A Convex deployment, so you have an HTTP Actions URL to point Apple at. - Two identifiers, not one. Apple needs an **App ID** _and_ a **Services ID**, and it is the Services ID — not the App ID — that becomes your `AUTH_APPLE_ID`. Getting these backwards is the most common failure here. ## Configure Apple **1.** Create an App ID 1. Go to [Certificates, Identifiers & Profiles](https://developer.apple.com/account/resources/certificates/list). 2. Select **Identifiers** in the sidebar, make sure **App IDs** is selected in the dropdown on the right, and click the **+** button. 3. On the Register a New Identifier page, keep **App IDs** selected and click **Continue**. 4. With **App** selected, click **Continue**. 5. Fill in **Description**, and set **Bundle ID** to **Explicit** with your app's identifier — e.g. `com.yourcompany.yourapp`. It must match the `ios.bundleIdentifier` in your `app.json`. 6. Scroll to **Capabilities** and check **Sign In with Apple**. 7. Click **Continue**, then **Register**. **2.** Create a Services ID 1. Back on Certificates, Identifiers & Profiles, switch the dropdown to **Services IDs** and click **+**. 2. Keep **Services IDs** selected and click **Continue**. 3. Fill in **Description** and **Identifier** — e.g. `com.yourcompany.yourapp.service`. This identifier is what you will set as `AUTH_APPLE_ID`, so it must be different from the App ID above. 4. Click **Continue**, then **Register**. **3.** Create a signing key 1. Click **Keys** in the sidebar, then **+**. 2. Give the key a name. 3. Check **Sign In with Apple** and click **Configure** beside it. 4. Select the **App ID** you created as the Primary App ID, and click **Save**. 5. Click **Continue**, then **Register**. 6. Download the `.p8` file. 7. Note the **Key ID** shown next to it, and your **Team ID** from the top right of the developer portal. > Apple lets you download a signing key exactly once. Store the `.p8` somewhere > safe and out of version control — if you lose it you have to revoke the key > and create a new one. **4.** Find your Convex HTTP Actions URL In the Convex dashboard, open **Settings → URL & Deploy Key** and copy the **HTTP Actions URL**. It ends in `.site`, not `.cloud` — the `.cloud` URL is what your app talks to for queries and mutations, and it is not what Apple redirects to. **5.** Configure the Services ID for web authentication 1. Return to **Identifiers**, switch the dropdown to **Services IDs**, and click the Services ID you created. 2. Make sure **Sign In with Apple** is checked and click **Configure**. 3. Set the **Primary App ID** to your App ID. 4. In **Domains and Subdomains**, enter just the domain portion of your HTTP Actions URL — no scheme, no path: ``` fast-horse-123.convex.site ``` 5. In **Return URLs**, enter the full callback URL: ``` https://fast-horse-123.convex.site/api/auth/callback/apple ``` 6. Click **Next**, confirm the values, and click **Done**. 7. Back on the Services ID page, click **Continue**, then **Save**. **6.** Generate the JWT client secret Apple does not issue a static secret. You sign one yourself with the `.p8` key, using four pieces of information: | Value | Where it comes from | | --------------- | ------------------------------------------------------ | | **Team ID** | Top right of the Apple Developer portal, 10 characters | | **Services ID** | The identifier from step 2, e.g. `com.you.app.service` | | **Key ID** | In the filename of the key, `AuthKey_XXXXXXXXXX.p8` | | **Private key** | The contents of the `.p8` file | ```js const jwt = require('jsonwebtoken'); const fs = require('fs'); const privateKey = fs.readFileSync('AuthKey_XXXXXXXXXX.p8'); const token = jwt.sign( { iss: 'YOUR_TEAM_ID', iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 15777000, // 6 months aud: 'https://appleid.apple.com', sub: 'YOUR_SERVICE_ID', }, privateKey, { algorithm: 'ES256', header: { kid: 'YOUR_KEY_ID' } } ); console.log(token); ``` Sign it locally. The `.p8` is a private key: pasting it into an online JWT generator hands whoever runs that page the ability to authenticate as your app. **7.** Set the environment variables ```bash npx convex env set AUTH_APPLE_ID your_service_id npx convex env set AUTH_APPLE_SECRET your_generated_jwt ``` Or add them in the Convex dashboard under **Settings → Environment Variables**. Set them on your production deployment too, with `--prod`. > Six months, maximum. When it lapses, Apple sign-in stops for everyone with no > code change and no deploy to blame. Put a calendar reminder on it the day you > set it up. ## Test it ```bash npx expo start ``` Tap **Login with Apple**. The browser sheet opens; sign in with an Apple ID. ## What Apple sends back Less than Google, and only once. `convex/auth.ts`'s `profile` callback reads whatever is present on that first authorization: ```ts title="convex/auth.ts" Apple({ profile: (appleInfo) => { const name = appleInfo.user ? `${appleInfo.user.name.firstName} ${appleInfo.user.name.lastName}` : undefined; return { id: appleInfo.sub, name: name, email: appleInfo.email, }; }, }), ``` `appleInfo.user` — the name — is only present on the very first sign-in. Every subsequent one omits it, so `name` in the returned profile is `undefined` from then on. If the user picked **Hide My Email**, `email` is a private relay address, not their real one. Capture whatever you need at first sign-in; do not build anything that depends on re-reading it later. ## When it does not work | What you see | Usually | | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | `invalid_client` | `AUTH_APPLE_ID` is the App ID instead of the Services ID | | `invalid_client` after months of working | The six-month JWT secret expired | | Redirect rejected | Return URL in the Services ID doesn't match `/api/auth/callback/apple` | | Browser closes, nothing happens | `EXPO_URL`/`SITE_URL` don't cover the redirect — see [the redirect allow-list](/docs/convex/auth#the-redirect-allow-list) | | Name is missing after the first sign-in | Working as intended — Apple sends it once | ## Next - [Google](/docs/convex/google) · [Resend](/docs/convex/resend) - [Authentication](/docs/convex/auth) - [Convex Auth Apple provider docs](https://labs.convex.dev/auth/config/oauth/apple) <!-- ---------------------------------------------------------------------- --> # Resend OTP > Step-by-step guide to configuring Resend for email OTP and password reset in the Convex Auth starter — the account, domain verification, the API key, and the from address you have to change. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/convex/resend - Markdown: https://ui.ahmedbna.com/docs/convex/resend.md --- Two flows in the starter send mail through [Resend](https://resend.com): the email OTP tab, and the password-reset code. ## Before you start - A [Resend](https://resend.com) account. The free tier is enough to test with. - A domain you own, with access to its DNS records. You can create an API key and send to your own address without one, but you cannot send to anyone else until a domain is verified. ## Set it up **1.** Create a Resend account Sign up at [resend.com](https://resend.com) and confirm your email address. The onboarding checklist that greets you covers exactly two things — adding an API key and sending a test email — and you will do both below. ![Resend onboarding checklist: add an API key and send your first email](https://demo.ahmedbna.com/0434-resend-setup-04.png) ![Resend onboarding checklist with both steps completed](https://demo.ahmedbna.com/0435-resend-setup-05.png) **2.** Add your domain 1. Click **Domains** in the left sidebar. 2. Click **Add Domain**. 3. Enter the domain or subdomain you want to send from, pick the region closest to your users, and click **Add Domain**. A subdomain like `mail.yourdomain.com` is worth considering: it keeps the sending reputation of your transactional mail separate from whatever else uses the root domain. ![Empty Resend Domains page with the Add Domain button highlighted](https://demo.ahmedbna.com/0436-resend-setup-06.png) ![Add Domain form with a domain name and region selected](https://demo.ahmedbna.com/0437-resend-setup-07.png) **3.** Add the DNS records Resend generates the records for you — an `MX` and two `TXT` records for DKIM and SPF, marked **Required**, plus an optional `TXT` record for DMARC. Copy each one into your DNS provider exactly as shown; do not retype the values. Then click **I've added the records**. Verification usually lands in a few minutes, but DNS propagation can take up to 24 hours. ![Resend DNS Records panel showing the required DKIM/SPF records and the optional DMARC record](https://demo.ahmedbna.com/0438-resend-setup-08.png) > Resend marks DMARC "Recommended" rather than "Required" — mail sends without > it. Adding a policy like `v=DMARC1; p=quarantine; > rua=mailto:dmarc@yourdomain.com` tells receiving servers what to do with mail > that fails SPF or DKIM, which is what keeps you out of spam folders as your > volume grows. **4.** Create an API key 1. Click **API Keys** in the left sidebar. 2. Click **Create API Key**. 3. Name it something you'll recognise later, leave **Permission** on **Full access**, and scope it to your domain if you want to restrict it. 4. Click **Add**. 5. Copy the key. Resend shows it once and never again. ![Resend API Keys page with the Create API Key button highlighted](https://demo.ahmedbna.com/0431-resend-setup-01.png) ![Add API Key dialog with a name, Full access permission and domain scope](https://demo.ahmedbna.com/0432-resend-setup-02.png) ![View API Key dialog warning that the key is only shown once](https://demo.ahmedbna.com/0433-resend-setup-03.png) **5.** Set it on your Convex deployment ```bash npx convex env set AUTH_RESEND_KEY re_your_key_here ``` Or add `AUTH_RESEND_KEY` in the Convex dashboard under **Settings → Environment Variables**. **6.** Use separate keys for dev and prod `npx convex env set` writes to your development deployment. Production is a separate deployment with its own environment, so set it there too: ```bash npx convex env set AUTH_RESEND_KEY re_prod_key_here --prod ``` Separate keys mean you can revoke one without taking the other down, and the Resend logs tell you which environment a send came from. > Both `convex/resendOTP.ts` and `convex/passwordReset.ts` ship with a > placeholder `from: 'BNA UI <ahmdabdelsamea@gmail.com>'`. Change it to an > address at your verified domain in both files — Resend will reject sends > from a domain you don't own, and even if it didn't, your users would be > getting mail from someone else's inbox. ## Test it ```bash npx convex dev ``` Try the OTP tab and the forgot-password flow. Without `AUTH_RESEND_KEY` set, both fail — the sign-up and sign-in flows in the password tab work regardless, since they don't send mail. Then open **Logs** in the Resend dashboard. Every send shows up there, whether it was delivered, bounced or rejected, along with the API error if there was one. ## No local mail catcher Supabase's local stack ships Inbucket, which catches every message instead of delivering it. There is no Convex equivalent — testing email here always sends through Resend, dev key or not. Resend's dashboard **Logs** is the closest thing to it: every send, delivered or not, shows up there. ## When it does not work | What you see | Usually | | --------------------------------- | ---------------------------------------------------------------------- | | Email never arrives, no error | `AUTH_RESEND_KEY` unset, or set on the wrong deployment (dev vs. prod) | | Arrives in spam | Domain has no SPF/DKIM, or the from address isn't on that domain | | `Could not send email` thrown | Check the Resend dashboard's Logs for the actual API error | | Works in dev, not after deploying | `AUTH_RESEND_KEY` was never set with `--prod` | | Domain stuck on "Pending" | DNS hasn't propagated yet, or a record was retyped instead of pasted | ## Next - [Google](/docs/convex/google) · [Apple](/docs/convex/apple) - [Authentication](/docs/convex/auth) - [Resend documentation](https://resend.com/docs) <!-- ---------------------------------------------------------------------- --> # Deployment > Ship a Convex-backed Expo app — dev versus prod deployments, EAS build profiles, the two-job CI workflow both starters include, migration safety, and the production checklist. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/convex/deployment - Markdown: https://ui.ahmedbna.com/docs/convex/deployment.md --- Two things deploy independently: your Convex functions (`npx convex deploy`) and your app (EAS build, or an over-the-air update). Unlike Supabase there is no separate migration step — pushing your functions also pushes the current `schema.ts`. ## Dev vs. prod deployments Every Convex project has (at least) two deployments: the **dev** deployment `npx convex dev` talks to while you work, and a separate **prod** deployment your shipped app should point at. Environment variables are set per deployment — `npx convex env set NAME value` targets dev, `--prod` targets prod. The [auth installation guide](/docs/installation/convex-auth#production) covers pointing `SITE_URL` and `EXPO_URL` at the right one before you ship. ## Environment variables `EXPO_PUBLIC_` variables are **inlined into the JavaScript bundle at build time** — they are not secret, and changing one needs a new build or OTA update, not a server restart. ```json title="eas.json" { "build": { "preview": { "distribution": "internal", "channel": "preview", "env": { "EXPO_PUBLIC_CONVEX_URL": "https://your-dev-deployment.convex.cloud" } }, "production": { "autoIncrement": true, "channel": "production", "env": { "EXPO_PUBLIC_CONVEX_URL": "https://your-prod-deployment.convex.cloud" } } } } ``` > It is easy to copy the dev URL into every profile because that is the one you > have been testing against. A production build pointed at your dev deployment > reads and writes data your local `npx convex dev` session also touches — not a > security problem the way a leaked secret key is, but a confusing one. ## Building ```bash eas build --platform all --profile preview eas build --platform all --profile production eas submit --platform ios --profile production ``` ## Over-the-air updates ```bash eas update --branch production --message "Fix the reset-password redirect" ``` OTA updates ship JavaScript only. Changing `scheme` in `app.json` needs a new build, and it also means updating `EXPO_URL` on your Convex deployment — see [the redirect allow-list](/docs/convex/auth#the-redirect-allow-list). ## The shipped workflow `.github/workflows/ci.yml` in both starters has two jobs — fewer than Supabase's four, and deliberately so. **`verify`** — `tsc --noEmit`, `expo lint`, `jest`. Runs on every PR, needs no live deployment because `convex/_generated/` is checked in. **`build`** — kicks off an EAS build on merge to `main`. There is no `types-are-current` job. Supabase's works because `supabase db start` spins up a throwaway local Postgres from checked-in migration files — nothing needs to be logged in. Convex has no equivalent: `_generated/` is produced by `npx convex dev` or `npx convex codegen` talking to an actual deployment, and there is no way to regenerate it from schema and function source alone without an authenticated session against somewhere. There is nothing to regenerate-and-diff in an unauthenticated CI job, so this job is dropped rather than faked. There is also no default **`deploy`** job. Supabase's runs non-interactively off an access token every Supabase-scaffolded user already has from creating their project. The Convex CLI's own `npx convex dev --once` — what `bna-ui convex` itself runs — opens a browser and cannot run in CI at all. ### Deploying for real ```bash npx convex deploy ``` Pushes your current `schema.ts` and functions to your **prod** deployment. Run it from your machine, or wire up the optional job below. ### Adding a CI deploy job (optional) `npx convex deploy` _can_ run non-interactively, with a deploy key from your dashboard's **Settings → Deploy Keys**: ```yaml title=".github/workflows/ci.yml" deploy: name: Deploy Convex functions if: github.ref == 'refs/heads/main' && github.event_name == 'push' needs: [verify] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 22, cache: npm } - run: npm ci - run: npx convex deploy env: CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }} ``` This is not in the shipped workflow because nothing in the scaffold prompts for or sets up that key — add it once you have one, rather than have every fresh `git push` fail on a secret that does not exist yet. ## Migration safety `npx convex deploy` pushes your current schema and functions. It does not roll back. - **Add fields as `v.optional(...)`, backfill with a mutation, then tighten it.** A required field on a table with existing documents fails the deploy until every row has one. - **Do not remove a field or function the deployed app still reads.** Deploy the app that stops reading it first. - **Deploy functions before the app that needs them.** A new query with old clients is harmless; a client calling a query that no longer exists is a crash. Assume old versions of your app are live for weeks — your users decide when they update, unlike a web deploy. ## Production checklist **1.** Point SITE\_URL and EXPO\_URL at production ```bash npx convex env set SITE_URL https://your-site.com --prod npx convex env set EXPO_URL your-scheme:// --prod ``` **2.** Set every provider's credentials against --prod too `AUTH_RESEND_KEY`, `AUTH_GOOGLE_ID`/`SECRET`, `AUTH_APPLE_ID`/`SECRET` — each one you configured for dev, again with `--prod`. **3.** Re-read every query and mutation as an attacker There is no RLS backstop here. Confirm every function that returns or changes user data checks `getAuthUserId(ctx)` against the right owner. See [database](/docs/convex/database#authorization-lives-in-the-function). **4.** Confirm the production EAS profile points at prod `EXPO_PUBLIC_CONVEX_URL` in the `production` build profile must be your prod deployment's URL, not the dev one you have been testing against. ## Monitoring The Convex dashboard's **Logs** and **Health** sections cover function errors, execution time and scheduled job failures. For the app side, `expo-updates` and EAS Insights cover adoption and crashes. ## Next - [Database and schema](/docs/convex/database) - [Troubleshooting](/docs/convex/troubleshooting) - [Expo EAS Build docs](https://docs.expo.dev/build/introduction/) <!-- ---------------------------------------------------------------------- --> # Troubleshooting > Common failures across auth, the database, realtime and deployment, a FAQ, and a Supabase to Convex migration guide. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/convex/troubleshooting - Markdown: https://ui.ahmedbna.com/docs/convex/troubleshooting.md --- ## Auth | Symptom | Usually | | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | | OAuth browser closes, nothing happens | The redirect isn't allow-listed — see [the redirect allow-list](/docs/convex/auth#the-redirect-allow-list) | | `Invalid redirectTo URI` | Same cause — `EXPO_URL`/`SITE_URL` don't match what `signIn` actually redirected to | | Sign-in works in dev, fails after shipping | Provider credentials or `EXPO_URL`/`SITE_URL` were never set with `--prod` | | Password sign-up rejected with no clear reason | `validatePasswordRequirements` in `convex/auth.ts` throws — the message it throws is what the form shows | | Email OTP or password reset does nothing | `AUTH_RESEND_KEY` is unset — see [Resend](/docs/convex/resend) | | Apple sign-in fails only in production | Apple requires a deployed HTTPS `.site` URL — see [Apple](/docs/convex/apple) | | A user's session never resolves, stuck on `AuthLoading` | Check the dashboard's Logs — a thrown error in `convex/auth.ts` looks like this from the client | ## Database | Symptom | Usually | | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | Deploy rejected after adding a field | The field is required (`v.string()`, not `v.optional(v.string())`) and existing documents don't have it | | A query returns data it shouldn't | There is no RLS backstop — the handler itself is missing an owner check, see [authorization](/docs/convex/database#authorization-lives-in-the-function) | | A query is slow on a large table | It's missing a `.withIndex(...)` and is scanning the table | | Types in `_generated/` look stale | `npx convex dev` isn't running — it regenerates on every schema/function save | **1.** Confirm the deployment matches your .env.local `npx convex env list` shows what's set on the deployment your CLI is currently pointed at. If it's empty and you expected values, you're probably looking at the wrong deployment (dev vs. prod). **2.** Check the dashboard's Data tab Confirms whether the document you expect actually exists, independent of whatever your query returns. **3.** Check the dashboard's Logs Query and mutation errors show up here even when the client sees nothing but `undefined`. ## Realtime | Symptom | Usually | | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | `useQuery` never resolves past `undefined` | The handler threw — see [debugging a stuck subscription](/docs/convex/realtime#debugging-a-stuck-subscription) | | Updates from one device don't reach another | Both need `EXPO_PUBLIC_CONVEX_URL` pointed at the same deployment | | A mutation appears to succeed but nothing changes | Check the mutation actually calls `ctx.db.patch`/`insert` — a no-op handler doesn't error | ## Storage Not used by either starter, so nothing here is starter-specific yet. The most common issue with a hand-rolled upload is forgetting `Content-Type` on the `POST` to the generated upload URL — see [storage](/docs/convex/storage). ## Actions Also not used by either starter. If a package you call from an action fails with a missing Node built-in, add `'use node'` to the top of the file — see [actions](/docs/convex/actions#reach-for-use-node-only-when-you-need-it). ## FAQ - **Can I use Convex without `@convex-dev/auth`?** Yes — `npx bna-ui convex --no-auth` skips it entirely. You can also swap in your own auth provider later; nothing about the schema or functions requires this one. - **Does Convex have a local dev stack, like `supabase start`?** No. `npx convex dev` talks to a real (free-tier) cloud dev deployment — there is no offline equivalent. - **How do I see what's actually stored?** The dashboard's **Data** tab, or `npx convex data <table>` from the CLI. - **Can two apps share one Convex deployment?** Yes — it's just an `EXPO_PUBLIC_CONVEX_URL` value. Nothing ties a deployment to one client. - **Why does my mutation see stale data from another mutation in the same request?** It shouldn't — mutations are transactional. If this happens, you're probably calling `ctx.runMutation` from inside an action twice without awaiting the first. - **Is there a Convex CLI equivalent of `supabase db reset`?** Not quite — there's no seed-and-rebuild-from-scratch command, because there's no local stack to rebuild. Deleting data is a dashboard or `npx convex run` action you write yourself. - **How do I run something on a schedule?** `ctx.scheduler.runAfter` for a one-off, `cronJobs()` in `convex/crons.ts` for recurring — see [actions](/docs/convex/actions#scheduling). ## Migrating from Supabase Both are TypeScript-first backends with realtime queries, so the shapes map closely — this is the mirror image of [Supabase's Migrating from Convex table](/docs/supabase/troubleshooting#migrating-from-convex). | Supabase | Convex | | --------------------------------------------------- | ---------------------------- | | `supabase/migrations/*.sql` | `convex/schema.ts` | | `create table …` | `defineTable({...})` | | RLS policies, enforced by Postgres | Function-level auth checks | | `select` + a `postgres_changes` subscription | `useQuery(api.tasks.list)` | | `supabase.from('tasks').insert(...)` | `useMutation(api.tasks.add)` | | Direct queries; edge functions for privileged work | `query` / `mutation` | | Edge function | `action` | | `auth.uid()` in a policy, `getUser()` in a function | `getAuthUserId(ctx)` | | `lib/database.types.ts` from `supabase gen types` | `_generated/api.d.ts` | | `providers/auth-provider.tsx` | `ConvexAuthProvider` | The real shift is where authorization lives. In Supabase the client talks to the database directly, so the check has to be a policy Postgres enforces. In Convex you check permissions inside each function — a query that forgot its check leaks through one endpoint, not the whole table, but there is no database-level backstop if you forget. Suggested order: **1.** Translate the schema One `defineTable` per migration, keeping the RLS policy's logic in mind for the auth checks you'll add to the functions that touch it. **2.** Move reads Each `select` plus subscription becomes a `query` plus `useQuery`. The policy's `using` clause becomes an explicit check in the handler. **3.** Move writes Each direct `insert`/`update`/`delete` becomes a `mutation`. The policy's `with check` becomes the same explicit check, before the write instead of enforced alongside it. **4.** Move privileged work Anything that was an edge function calling third-party APIs or using the service-role key becomes an `action`. **5.** Move auth last `AuthProvider` → `ConvexAuthProvider`. Users do not transfer between the two systems; plan a re-registration or a scripted import. ## Still stuck - [Report an issue](https://github.com/ahmedbna/ui/issues) - [Convex documentation](https://docs.convex.dev) · [Convex Auth documentation](https://labs.convex.dev/auth) <!-- ---------------------------------------------------------------------- --> # Supabase > A React Native starter with BNA UI components and a Supabase backend — Postgres, row level security, realtime, storage and edge functions, with or without authentication. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/supabase - Markdown: https://ui.ahmedbna.com/docs/supabase.md --- [Supabase](https://supabase.com) is Postgres with an API in front of it. You write SQL migrations and row level security policies; the client library gives you typed queries, realtime subscriptions, file storage, authentication and Deno edge functions against the same database. BNA UI ships two Supabase scaffolds: one with a backend and no sign-in, one with authentication and the screens already built. [Expo + Supabase](/docs/installation/supabase) — Migrations, RLS, realtime, storage and an edge function. No sign-in. [Expo + Supabase + Auth](/docs/installation/supabase-auth) — Password, magic links, OTP, Google, Apple and GitHub, pre-wired. ## The auth starter Built around the same principles as the rest of BNA UI: - **Open Code:** the authentication screens, the hooks and the SQL migrations are copied into your project, not imported from a package. - **Mobile-First:** flows designed for React Native, with the session encrypted in the platform keychain. - **Cross-Platform:** iOS, Android and web, from one code path. - **Secure by default:** row level security on every table, scoped by `auth.uid()` — not by what the client asks for. ### Sign-in methods | Method | Mechanism | Ships with a screen | | ---------------- | ------------------------------- | ------------------- | | Email + password | `signInWithPassword` | Yes | | Magic link | `signInWithOtp` + deep link | Yes | | Email OTP | `signInWithOtp` + `verifyOtp` | Yes | | Google | `signInWithOAuth`, browser PKCE | Yes | | Apple | `signInWithOAuth`, browser PKCE | Yes | | GitHub | `signInWithOAuth`, browser PKCE | Yes | All three OAuth providers share one code path, so adding a fourth is a line in an array. ### What lands in your project ``` lib/ ├── supabase.ts the client ├── large-secure-store.ts AES-256 session storage ├── realtime.ts the postgres_changes reducer, as a pure function └── database.types.ts generated from your schema providers/auth-provider.tsx session, profile, deep links app/(auth)/ six screens app/(onboarding)/ intro carousel + profile setup supabase/migrations/ profiles, tasks, storage — with RLS on all of them supabase/functions/ hello-world, delete-account ``` ### The two layers of access control The route guards in `app/_layout.tsx` decide what renders: ```tsx title="app/_layout.tsx" <Stack.Protected guard={!signedIn}> <Stack.Screen name='(auth)' /> </Stack.Protected> <Stack.Protected guard={signedIn && !needsOnboarding}> <Stack.Screen name='(tabs)' /> </Stack.Protected> ``` The RLS policies decide what the database will actually return: ```sql title="supabase/migrations/0002_tasks.sql" create policy "Users can read their own tasks" on public.tasks for select to authenticated using (auth.uid() = user_id); ``` Only the second one is a security boundary. The first is there so users are not looking at empty screens. ### Reading the signed-in user ```tsx import { useAuth } from '@/providers/auth-provider'; const { user, profile, loading, signOut } = useAuth(); ``` `profile` is the row from `public.profiles`, kept current over a realtime subscription. `user` is the `auth.users` record Supabase owns. ## Environment | Variable | Where it lives | Set by | Used for | | -------------------------------------- | -------------- | ----------------- | ---------------------------------- | | `EXPO_PUBLIC_SUPABASE_URL` | `.env.local` | `bna-ui supabase` | Building the client | | `EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY` | `.env.local` | `bna-ui supabase` | Building the client | | `SUPABASE_SERVICE_ROLE_KEY` | Edge functions | Supabase | Server-side work that bypasses RLS | | `SUPABASE_ANON_KEY` | Edge functions | Supabase | Server-side work that respects RLS | > Supabase is retiring the legacy `anon` and `service_role` keys at the end of > 2026\. Both starters use `sb_publishable_…` in the app and `sb_secret_…` > server-side. The publishable key carries exactly the privileges your RLS > policies grant the `anon` role, which is why those policies matter. Provider credentials — Google, Apple, GitHub, SMTP — live in the Supabase dashboard, not in any file in your repository. ## Guides - [Database, RLS and migrations](/docs/supabase/database) — the schema, every policy explained, and the codegen workflow - [Authentication](/docs/supabase/auth) — session persistence, route guards, deep links, and swapping to native OAuth - [Google](/docs/supabase/google) · [Apple](/docs/supabase/apple) · [Email and SMTP](/docs/supabase/email) - [Storage](/docs/supabase/storage) · [Realtime](/docs/supabase/realtime) · [Edge functions](/docs/supabase/edge-functions) - [Deployment](/docs/supabase/deployment) — EAS, CI/CD, and the production checklist - [Troubleshooting](/docs/supabase/troubleshooting) — and a Convex → Supabase migration guide ## Learn more - [Supabase documentation](https://supabase.com/docs) - [Report an issue](https://github.com/ahmedbna/ui/issues) <!-- ---------------------------------------------------------------------- --> # Authentication > How the Supabase Auth starter persists a session, guards routes, handles deep links and runs OAuth — and how to swap the browser flow for native sign-in sheets. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/supabase/auth - Markdown: https://ui.ahmedbna.com/docs/supabase/auth.md --- The auth starter has four moving parts: a client configured for React Native, a provider that owns session state, route guards that decide what renders, and RLS policies that decide what the database returns. Only the last one is a security boundary. ## The client ```ts title="lib/supabase.ts" export const supabase = createClient<Database>(supabaseUrl, supabaseKey, { auth: { storage: Platform.OS === 'web' ? undefined : new LargeSecureStore(), persistSession: true, autoRefreshToken: true, detectSessionInUrl: Platform.OS === 'web', flowType: 'pkce', }, }); ``` | Option | Why it is set this way | | -------------------- | ------------------------------------------------------------------------ | | `storage` | Encrypted device storage on native; the browser's own on web | | `detectSessionInUrl` | There is no URL on native — leaving it on makes sign-in appear to hang | | `flowType: 'pkce'` | The code in a deep link is worthless without the verifier held on-device | ### Session storage `expo-secure-store` refuses values over 2048 bytes. A Supabase session is several kilobytes. Writing one directly fails — and it fails _later than you would like_, because a test account with no metadata can fit under the limit, so it works all through development and breaks on your first real user. `lib/large-secure-store.ts` splits the problem: a 256-bit AES key in SecureStore (which is good at small secrets), the ciphertext in AsyncStorage (which has no size limit). ```ts title="lib/large-secure-store.ts" async setItem(key: string, value: string) { const encrypted = await this._encrypt(key, value); await AsyncStorage.setItem(key, encrypted); } ``` A ciphertext whose key is gone — reinstalled on Android, Keychain cleared — is unrecoverable, so `getItem` drops it and returns `null`. The user lands on the sign-in screen instead of an infinite spinner. Plain AsyncStorage also works and is simpler. It leaves the refresh token readable by anything that can reach the app's sandbox, which on a rooted or jailbroken device is a real difference. ### Token refresh ```ts title="lib/supabase.ts" AppState.addEventListener('change', (state) => { if (state === 'active') supabase.auth.startAutoRefresh(); else supabase.auth.stopAutoRefresh(); }); ``` `autoRefreshToken` runs on a timer, and iOS suspends timers in backgrounded apps. Without this listener, a user who leaves the app for an hour comes back to failing requests with no obvious cause. ## The provider `providers/auth-provider.tsx` holds `session`, `user`, `profile` and `loading`, and exposes `useAuth()`. ```ts title="providers/auth-provider.tsx" supabase.auth.getSession().then(({ data }) => { setSession(data.session); setLoading(false); }); const { data: { subscription }, } = supabase.auth.onAuthStateChange((_event, nextSession) => { setSession(nextSession); setLoading(false); }); ``` `getSession` reads the persisted session off disk; `onAuthStateChange` covers everything after — sign-in, sign-out, token refresh, and sessions established by a deep link. `loading` matters: rendering the navigator before the session resolves flashes the sign-in screen at a user who is already signed in. The profile is loaded with `maybeSingle`, not `single`, because it is created by a database trigger and the first read can land before the trigger commits. A null profile means "not yet", not "error". A realtime subscription on the row keeps it current when onboarding writes to it from another screen. ## Route guards ```tsx title="app/_layout.tsx" const signedIn = !!session; const needsOnboarding = signedIn && profile?.onboarded === false; <Stack.Protected guard={!signedIn}> <Stack.Screen name='(auth)' /> </Stack.Protected> <Stack.Protected guard={needsOnboarding}> <Stack.Screen name='(onboarding)' /> </Stack.Protected> <Stack.Protected guard={signedIn && !needsOnboarding}> <Stack.Screen name='(tabs)' /> <Stack.Screen name='sheet' /> </Stack.Protected> ``` The three guards are mutually exclusive, so exactly one group is mounted. `Stack.Protected` unmounts the screens whose guard is false and redirects away from them, so there is no navigation path into `(tabs)` without a session — deep link included. Note `profile?.onboarded === false` rather than `!profile?.onboarded`. While the trigger is still committing, `profile` is `null`; the loose version would treat that as "not onboarded" and flash the onboarding flow at returning users. > They decide what renders. Anyone can run a modified build. The policies in > `supabase/migrations/` are what actually stop one user reading another's rows, > and they run in Postgres where the client cannot argue. See [database and > RLS](/docs/supabase/database). ## Deep links Magic links, email confirmations and password recovery all return to the app as a URL. The starter handles them on the provider rather than in a route, because the link can arrive while any screen is mounted — and on a cold start, before the router has settled anywhere. ```ts title="providers/auth-provider.tsx" const handleUrl = async (url: string) => { const { queryParams } = Linking.parse(url); // PKCE: the link carries ?code=… const code = queryParams?.code; if (typeof code === 'string') { await supabase.auth.exchangeCodeForSession(code); return; } // Implicit: older projects and some templates return tokens in the #fragment const fragment = url.split('#')[1]; if (!fragment) return; const params = new URLSearchParams(fragment); const access_token = params.get('access_token'); const refresh_token = params.get('refresh_token'); if (access_token && refresh_token) { await supabase.auth.setSession({ access_token, refresh_token }); } }; Linking.getInitialURL().then((url) => url && handleUrl(url)); Linking.addEventListener('url', ({ url }) => handleUrl(url)); ``` Both shapes are handled because which one you get depends on your project's settings and email templates, and getting it wrong looks identical to a broken link. ## OAuth One code path for Google, Apple and GitHub: ```ts title="components/auth/oauth-buttons.tsx" const redirectTo = makeRedirectUri(); const { data } = await supabase.auth.signInWithOAuth({ provider, options: { redirectTo, skipBrowserRedirect: true }, }); const result = await openAuthSessionAsync(data.url, redirectTo); if (result.type !== 'success') return; // dismissed const code = new URL(result.url).searchParams.get('code'); await supabase.auth.exchangeCodeForSession(code); ``` `makeRedirectUri()` resolves to `exp://…` under Expo Go and `<scheme>://` in a build, reading `scheme` from `app.json`. **Both spellings must be in your project's redirect allow-list** — this is the most common OAuth failure. Adding a fourth provider is a line in the `PROVIDERS` array, assuming it is configured in the dashboard. ### Switching to native sign-in Browser PKCE works everywhere, including Expo Go. Native sheets look better and, on iOS, are what users expect. The trade is a development build and per-platform configuration. For Apple: ```bash npx expo install expo-apple-authentication ``` ```tsx import * as AppleAuthentication from 'expo-apple-authentication'; const credential = await AppleAuthentication.signInAsync({ requestedScopes: [ AppleAuthentication.AppleAuthenticationScope.FULL_NAME, AppleAuthentication.AppleAuthenticationScope.EMAIL, ], }); await supabase.auth.signInWithIdToken({ provider: 'apple', token: credential.identityToken!, }); ``` Add `"expo-apple-authentication"` to `plugins` in `app.json`, and enable the Apple provider in Supabase with your bundle identifier as an additional client ID. Google's native flow uses `@react-native-google-signin/google-signin` and the same `signInWithIdToken` shape. It needs a web client ID and an iOS client ID, and there is a well-known nonce mismatch on iOS — Google's SDK skips the nonce by default while Supabase expects one, unless you opt out. > If your iOS app offers any third-party sign-in, App Store review requires Sign > in with Apple as an option. The browser flow satisfies this; the native sheet > is a better experience. ## Password rules `app/(auth)/sign-up.tsx` and `reset-password.tsx` share a `describePasswordProblem` function that mirrors what Supabase enforces server-side, so the user is told before the round trip: ```ts function describePasswordProblem(password: string): string | undefined { if (password.length < 8) return 'At least 8 characters.'; if (!/[a-z]/.test(password)) return 'Include a lowercase letter.'; if (!/[A-Z]/.test(password)) return 'Include an uppercase letter.'; if (!/[0-9]/.test(password)) return 'Include a digit.'; return undefined; } ``` Change it together with the project's policy under **Authentication → Providers → Email**, or one of the two will lie. ## Deleting an account Removing a user requires a secret key, which must never reach the app bundle. `supabase/functions/delete-account` holds one server-side and identifies the caller from their own JWT, never from the request body: ```ts title="supabase/functions/delete-account/index.ts" const { data: { user }, } = await asUser.auth.getUser(); // …then, only after the caller is known: const asAdmin = createClient(url, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!); await asAdmin.auth.admin.deleteUser(user.id); ``` `profiles` and `tasks` both cascade from `auth.users`. Storage objects do not, so the function removes them explicitly. ## Next - [Google](/docs/supabase/google) · [Apple](/docs/supabase/apple) · [Email and SMTP](/docs/supabase/email) - [Database and RLS](/docs/supabase/database) - [Troubleshooting](/docs/supabase/troubleshooting) <!-- ---------------------------------------------------------------------- --> # Database and RLS > The schema both Supabase starters ship, every row level security policy explained, and the migration and type-generation workflow that keeps your code and your database in step. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/supabase/database - Markdown: https://ui.ahmedbna.com/docs/supabase/database.md --- Your Postgres schema lives in `supabase/migrations/` as plain SQL, applied in filename order and checked into git. There is no schema DSL and no ORM — the migration is the source of truth, and `lib/database.types.ts` is generated from it. ## The workflow **1.** Write a migration ```bash npx supabase migration new add_projects ``` Creates `supabase/migrations/<timestamp>_add_projects.sql`. Write your DDL in it, including `enable row level security` and the policies. **2.** Apply it ```bash npm run db:push # to your linked project npm run db:reset # or: rebuild the local stack from scratch + seed.sql ``` **3.** Regenerate the types ```bash npm run db:types ``` **4.** Commit the migration and the types together The shipped `.github/workflows/ci.yml` has a job that rebuilds the types from your migrations and fails if the checked-in file differs, so this is enforced rather than remembered. > If you changed the schema by clicking around in Studio, `npx supabase db diff > -f add_projects` (aliased to `npm run db:diff`) writes the difference out as a > migration instead of leaving your local and remote schemas divergent. ## Typed queries `lib/database.types.ts` is passed to `createClient` as a generic, which is what makes everything downstream typed: ```ts title="lib/supabase.ts" export const supabase = createClient<Database>(url, key, {/* … */}); ``` From there, column names, filters and return types are all checked: ```ts const { data } = await supabase .from('tasks') .select('id, text, is_complete') .eq('is_complete', false); // data: { id: string; text: string; is_complete: boolean }[] | null ``` The starters also export the row types by name, which is usually what you want in a component signature: ```ts title="lib/database.types.ts" export type Tables<T extends keyof PublicSchema['Tables']> = PublicSchema['Tables'][T]['Row']; export type Profile = Tables<'profiles'>; export type Task = Tables<'tasks'>; ``` > CI has no database to generate it from, and neither does a fresh clone. Commit > it with every migration. ## Row level security RLS is the whole security model. The publishable key is compiled into your app bundle, so anyone with the app has it — your policies are the only thing deciding what that key can do. Two rules cover most of it: 1. **Enable RLS on every table in `public`.** A new table has it off. Adding policies to a table without it does nothing. 2. **Never trust a column the client supplies.** Verify it with `with check`. ### The anatomy of a policy ```sql title="supabase/migrations/0002_tasks.sql" create policy "Users can update their own tasks" on public.tasks for update to authenticated using (auth.uid() = user_id) with check (auth.uid() = user_id); ``` | Clause | What it decides | | ------------------ | -------------------------------------------------------------------- | | `for update` | Which operation. Separate policies for `select`, `insert`, `delete`. | | `to authenticated` | Which role. `anon` is an unauthenticated caller. | | `using` | Which existing rows are visible to this operation | | `with check` | What the row is allowed to look like **after** the write | `using` without `with check` on an update lets a user take a row they own and reassign it to somebody else. Both clauses, every time. `auth.uid()` reads the id out of the caller's JWT. It is `null` for an anonymous caller, so every policy above fails closed. ### Insert policies only have `with check` There is no existing row to test, so this is the one that stops a client claiming ownership it does not have: ```sql create policy "Users can create their own tasks" on public.tasks for insert to authenticated with check (auth.uid() = user_id); ``` The client still sends `user_id` — see `hooks/useTasks.ts` — but sending somebody else's is rejected by the database. ### The no-auth starter is different Its policies are `using (true)` for the `anon` role, which is correct for a public demo and wrong for real data: ```sql title="supabase/migrations/0001_tasks.sql" create policy "Anyone can read tasks" on public.tasks for select to anon, authenticated using (true); ``` Anyone who extracts the publishable key from your app can do exactly what these policies allow, from curl, forever. Tighten them or move to the auth starter before you put anything real behind them. ### Indexes follow policies Every policy that filters on a column makes every query filter on that column. Without an index, each one is a sequential scan: ```sql title="supabase/migrations/0002_tasks.sql" create index tasks_user_id_created_at_idx on public.tasks (user_id, created_at desc); ``` This is the most common performance problem in an RLS-heavy schema, and it does not show up until the table is large. ### Testing a policy The honest test is two accounts. Sign in as one, create a row, sign in as the other, and confirm it is not there. Locally you can also ask Postgres directly: ```sql set local role authenticated; set local request.jwt.claims = '{"sub": "<some-user-id>"}'; select * from public.tasks; -- only that user's rows reset role; ``` ## Triggers The auth starter creates a profile row the moment a user exists, rather than from the client: ```sql title="supabase/migrations/0001_profiles.sql" create trigger on_auth_user_created after insert on auth.users for each row execute function public.handle_new_user(); ``` Doing it in the database means it cannot be skipped by a user who closes the app mid-sign-up, and the app never has to handle "signed in but no profile yet" as a permanent state. > `handle_new_user` runs as its owner so it can write to a table the caller has > no insert policy for. Without `set search_path = ''`, a schema earlier in the > path could shadow `public.profiles` and capture the insert. Pin it on every > definer function you write. ## Realtime needs two lines ```sql alter publication supabase_realtime add table public.tasks; alter table public.tasks replica identity full; ``` The publication is what makes changes broadcast at all — without it a subscription connects and then stays silent. `replica identity full` is what puts the whole row in `UPDATE` and `DELETE` payloads instead of just the primary key. See [realtime](/docs/supabase/realtime). ## Migrations in production `supabase db push` applies anything not yet recorded in the remote migration history. It does not roll back, and Postgres DDL that rewrites a large table takes a lock — so the usual advice applies: add columns as nullable, backfill separately, and drop nothing until nothing reads it. The shipped CI workflow pushes migrations on merge to `main`. See [deployment](/docs/supabase/deployment). ## Next - [Realtime](/docs/supabase/realtime) · [Storage](/docs/supabase/storage) - [Edge functions](/docs/supabase/edge-functions) - [Supabase RLS documentation](https://supabase.com/docs/guides/database/postgres/row-level-security) <!-- ---------------------------------------------------------------------- --> # Realtime > Live Postgres subscriptions in Expo — the two SQL lines that make changes broadcast at all, reconciling events with optimistic updates, reconnect behaviour, and presence and broadcast channels. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/supabase/realtime - Markdown: https://ui.ahmedbna.com/docs/supabase/realtime.md --- Supabase Realtime pushes database changes to subscribed clients over a WebSocket. Both starters use it for the task list; the reducer that folds events into local state is a pure function in `lib/realtime.ts` so it can be tested without a database. ## Two lines of SQL first A subscription with neither of these connects successfully and then never fires, which is the single most common "realtime is broken" report. ```sql title="supabase/migrations/0002_tasks.sql" alter publication supabase_realtime add table public.tasks; alter table public.tasks replica identity full; ``` **The publication** is what makes the table broadcast at all. A table you add later is silent until you add it here too. **`replica identity full`** is what puts the entire row in `UPDATE` and `DELETE` payloads. Without it Postgres sends only the primary key, so a `DELETE` handler cannot tell whose row it was and an `UPDATE` handler has nothing to merge. It has a cost — the WAL carries the whole old row on every write — so on a very high-write table, consider `replica identity default` and a refetch instead. ## Subscribing ```ts title="hooks/useTasks.ts" const channel = supabase .channel(`tasks:${userId}`) .on<Task>( 'postgres_changes', { event: '*', schema: 'public', table: 'tasks', filter: `user_id=eq.${userId}`, }, (payload) => setTasks((prev) => applyChange(prev, payload)) ) .subscribe(); return () => { supabase.removeChannel(channel); }; ``` The cleanup is not optional. Without `removeChannel`, a screen that mounts twice holds two subscriptions and every event is applied twice. > A client is only sent changes to rows its own policies would let it select. > The `filter` above is a bandwidth optimisation, not a security control — the > policies in `0002_tasks.sql` are what stop one user seeing another's events. ## Reconciling with optimistic updates Realtime echoes back every write, including the ones this device made. Applying an optimistic insert _and_ the echo gives you the row twice. ```ts title="lib/realtime.ts" case 'INSERT': { const row = payload.new; if (tasks.some((task) => task.id === row.id)) return tasks; return [row, ...tasks].sort(byNewest); } ``` Deduplicating by id lets the hook apply the mutation immediately — realtime can be several hundred milliseconds behind, and waiting for it makes the UI feel broken on a slow connection. The mutations roll back on failure: ```ts title="hooks/useTasks.ts" const toggle = useCallback(async (task: Task) => { const next = !task.is_complete; setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, is_complete: next } : t)) ); const { error } = await supabase .from('tasks') .update({ is_complete: next }) .eq('id', task.id); if (error) { setTasks((prev) => prev.map((t) => (t.id === task.id ? task : t))); } }, []); ``` Optimistic without rollback is just lying to the user. ## Reconnecting A backgrounded app or a dropped network means the socket closed, and everything that happened while it was away was missed. The events do not queue. ```ts title="hooks/useTasks.ts" .subscribe((status) => { setConnected(status === 'SUBSCRIBED'); if (status === 'SUBSCRIBED') load(); }); ``` Refetching on every `SUBSCRIBED` — including the first — is the simplest thing that is actually correct. The alternative, trusting the cache across a reconnect, silently diverges. The starters also surface `connected` in the UI as a small dot, because "is it live right now" is otherwise invisible. ## Offline There is no built-in offline queue. What the starters do: - Reads render from state, so a backgrounded app still shows its last data. - Writes fail loudly and roll back rather than appearing to succeed. - Reconnecting refetches. For genuine offline-first — queued mutations, conflict resolution, local persistence — you want a sync layer on top. That is a different architecture than a starter should pick for you. ## Presence and broadcast `postgres_changes` is one of three channel types. **Broadcast** sends ephemeral messages between clients with no database round trip. Good for typing indicators and cursors: ```ts const channel = supabase.channel('room:1'); channel .on('broadcast', { event: 'typing' }, ({ payload }) => { console.log(payload.user, 'is typing'); }) .subscribe(); channel.send({ type: 'broadcast', event: 'typing', payload: { user: 'ada' } }); ``` **Presence** tracks who is currently on a channel and syncs it automatically: ```ts const channel = supabase.channel('room:1'); channel .on('presence', { event: 'sync' }, () => { console.log(channel.presenceState()); }) .subscribe(async (status) => { if (status === 'SUBSCRIBED') { await channel.track({ user_id: user.id, online_at: new Date().toISOString(), }); } }); ``` Presence state is held in memory on the server and disappears when the client disconnects — which is the point, and also why it is not a substitute for an `is_online` column if you need durability. ## Debugging a silent subscription **1.** Is the table in the publication? ```sql select tablename from pg_publication_tables where pubname = 'supabase_realtime'; ``` **2.** Log the subscribe status ```ts .subscribe((status, err) => console.log(status, err)); ``` `CHANNEL_ERROR` usually means the filter is malformed. `TIMED_OUT` means the socket never opened — check the URL and key. **3.** Can the same query read the row? If a plain `select` returns nothing, RLS is filtering it, and realtime will filter it identically. **4.** Is Realtime enabled for the project? **Database → Replication** in the dashboard. ## Next - [Database and RLS](/docs/supabase/database) - [Troubleshooting](/docs/supabase/troubleshooting) - [Supabase Realtime docs](https://supabase.com/docs/guides/realtime) <!-- ---------------------------------------------------------------------- --> # Storage > Upload files from Expo to Supabase Storage — buckets, owner-scoped policies, reading a file URI without base64, public versus signed URLs, and the cache-busting problem avatars run into. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/supabase/storage - Markdown: https://ui.ahmedbna.com/docs/supabase/storage.md --- Supabase Storage is S3-compatible object storage with the same row level security model as the database — policies on `storage.objects` decide who can read and write what. ## Buckets The auth starter creates two, and the difference is the whole design decision: ```sql title="supabase/migrations/0003_storage.sql" insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types) values ('avatars', 'avatars', true, 2097152, array['image/jpeg', 'image/png', 'image/webp']); insert into storage.buckets (id, name, public, file_size_limit) values ('files', 'files', false, 10485760); ``` | | `public: true` | `public: false` | | -------- | ----------------------------------------------------------- | ------------------------------------ | | Read | `getPublicUrl()` — anyone with the URL, no token | `createSignedUrl()` — time-limited | | Good for | Avatars, product images, anything already shown to everyone | Documents, exports, anything private | | Caching | CDN-cached, cheap | Not cached, each URL is minted | `public` only affects reads. Writes are always governed by policies. `file_size_limit` and `allowed_mime_types` are enforced server-side, which is why the upload hooks set `contentType` explicitly — an upload whose declared type is not on the list is rejected. ## Owner-scoped policies `storage.objects` has RLS enabled already and ships with no policies, so an un-policied bucket rejects everything. The pattern that makes a shared bucket into per-user namespaces: ```sql title="supabase/migrations/0003_storage.sql" create policy "Users can upload their own avatar" on storage.objects for insert to authenticated with check ( bucket_id = 'avatars' and (storage.foldername(name))[1] = auth.uid()::text ); ``` `storage.foldername(name)` splits the object path into segments; `[1]` is the first. Requiring it to equal the caller's id means an object at `<user-id>/avatar.jpg` can only be written by that user. This is why the upload hook builds the path the way it does: ```ts title="hooks/useAvatarUpload.ts" const path = `${user.id}/avatar.${extension}`; ``` The path is not cosmetic. Change its shape and the policy stops matching. > The `avatars` bucket is public so avatars render in a plain `<Image>` with no > token, while the insert, update and delete policies still restrict writes to > the owner. Public does not mean unprotected. ## Uploading from Expo The part people get wrong is reading the file. `expo-image-picker` gives you a local URI; Supabase wants bytes. ```ts title="hooks/useAvatarUpload.ts" const body = await fetch(asset.uri).then((res) => res.arrayBuffer()); const { error } = await supabase.storage .from('avatars') .upload(path, body, { contentType, upsert: true }); ``` Two things matter here: **Use `arrayBuffer()`, not base64.** `ImagePicker`'s `base64: true` option is tempting, but base64 is a third larger than the bytes it encodes and has to be held in JavaScript memory in one piece. That is what makes large photos fail on Android. **Set `contentType` explicitly.** Supabase defaults to `application/octet-stream`, which makes the object download rather than render and fails the bucket's `allowed_mime_types` check. `upsert: true` replaces the existing object instead of failing on a name clash — correct for a single avatar per user, wrong if you want history. ## Reading Public bucket: ```ts const { data: { publicUrl }, } = supabase.storage.from('avatars').getPublicUrl(path); ``` This is a pure string operation — no network call, no token, and it does not verify the object exists. Private bucket: ```ts const { data } = await supabase.storage .from('files') .createSignedUrl(path, 60 * 60); // one hour // data.signedUrl ``` This one is a request, it respects your select policy, and the URL expires. ## The cache-busting problem An avatar at a stable path gets a stable URL, so after an upload the CDN and the device's image cache both keep serving the old image. The user changes their photo and nothing appears to happen. ```ts title="hooks/useAvatarUpload.ts" return `${publicUrl}?v=${Date.now()}`; ``` The query string is ignored by storage and treated as a different resource by every cache in between. The alternative is a unique path per upload plus a cleanup job; for one avatar per user this is simpler. ## Listing and deleting ```ts const { data } = await supabase.storage.from('files').list(user.id, { limit: 100, sortBy: { column: 'created_at', order: 'desc' }, }); await supabase.storage.from('files').remove([`${user.id}/report.pdf`]); ``` `list` takes a prefix, and with owner-scoped policies the prefix has to be the user's id — listing the bucket root returns nothing useful. > `profiles` and `tasks` are removed when a user is, because they reference > `auth.users` with `on delete cascade`. Objects have no such foreign key, so > `supabase/functions/delete-account` removes them explicitly. Anything you > store per user needs the same treatment. ## Larger files `upload` sends the whole body in one request, which is fine into the tens of megabytes. Past that, use resumable uploads (TUS) or a signed upload URL that the device streams to directly. Both are documented upstream; neither is in the starter, because the shape of the code changes enough that a demo would be misleading. ## Next - [Database and RLS](/docs/supabase/database) - [Edge functions](/docs/supabase/edge-functions) - [Supabase Storage docs](https://supabase.com/docs/guides/storage) <!-- ---------------------------------------------------------------------- --> # Edge Functions > Write, serve, deploy and invoke Deno edge functions from Expo — running as the caller versus as an admin, JWT verification, CORS, secrets, and why deleting a user has to happen server-side. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/supabase/edge-functions - Markdown: https://ui.ahmedbna.com/docs/supabase/edge-functions.md --- Edge functions are Deno running close to your database. Use one when the work needs a secret the app must not hold, or when it should happen server-side regardless of what the client does. Both starters ship `hello-world`; the auth starter also ships `delete-account`, which exists because there is no safe way to do it from the app. ## The two client shapes This is the decision that matters in every function you write. **As the caller** — forward their `Authorization` header. Queries run under the same RLS policies the app has, so the function can only see what the user can: ```ts title="supabase/functions/hello-world/index.ts" const authHeader = req.headers.get('Authorization')!; const supabase = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_ANON_KEY')!, { global: { headers: { Authorization: authHeader } } } ); const { data: { user }, } = await supabase.auth.getUser(); // Scoped to this user's rows by RLS, not by a where clause. const { count } = await supabase .from('tasks') .select('*', { count: 'exact', head: true }); ``` **As an admin** — the secret key, which bypasses RLS entirely. Only after you have established who is calling, and never with an id taken from the request body: ```ts title="supabase/functions/delete-account/index.ts" // 1. Who is this? From their token, not from what they sent. const { data: { user }, } = await asUser.auth.getUser(); if (!user) return new Response('Not authenticated', { status: 401 }); // 2. Only now, the powerful client. const asAdmin = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! ); await asAdmin.auth.admin.deleteUser(user.id); ``` > A function that deletes `body.userId` with an admin client lets any signed-in > user delete anybody. The caller's identity comes from their JWT, which they > cannot forge. ## Running one ```bash npx supabase functions serve # all functions, hot-reloading npx supabase functions serve hello-world --no-verify-jwt ``` Against the local stack this picks up `supabase/functions/` directly. Logs go to the terminal. ## Deploying ```bash npm run functions:deploy # all of them npx supabase functions deploy hello-world ``` The shipped CI workflow deploys on merge to `main`. See [deployment](/docs/supabase/deployment). ## JWT verification By default a deployed function rejects requests without a valid JWT before your code runs. That is what you want for anything user-specific. ```bash # No auth in this project, so the function accepts anonymous callers: npx supabase functions deploy hello-world --no-verify-jwt ``` The no-auth starter deploys with `--no-verify-jwt`; the auth starter does not, for either function. `delete-account` in particular must never accept an unauthenticated request. ## Invoking from Expo ```ts const { data, error } = await supabase.functions.invoke('hello-world'); ``` `invoke` attaches the current session's access token automatically, which is what makes the "as the caller" pattern above work. With a body: ```ts const { data, error } = await supabase.functions.invoke('send-report', { body: { month: '2026-07' }, }); ``` `error` is a `FunctionsHttpError` for a non-2xx response. The body is not parsed into it, so if you return structured errors, read them explicitly: ```ts if (error instanceof FunctionsHttpError) { const details = await error.context.json(); } ``` ## CORS Both shipped functions answer the preflight: ```ts const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', }; if (req.method === 'OPTIONS') { return new Response('ok', { headers: corsHeaders }); } ``` Native requests are not preflighted, so this looks like dead code until you run the app on web — where a missing `OPTIONS` handler fails every call. Tighten `Allow-Origin` to your own domain in production. ## Secrets ```bash npx supabase secrets set STRIPE_SECRET_KEY=sk_live_... npx supabase secrets list ``` Read them with `Deno.env.get('STRIPE_SECRET_KEY')`. `SUPABASE_URL`, `SUPABASE_ANON_KEY` and `SUPABASE_SERVICE_ROLE_KEY` are injected automatically. This is where every credential that must not ship in the app belongs. ## TypeScript and the editor `supabase/functions/` is Deno, not React Native. It resolves `jsr:` specifiers and the `Deno` global, neither of which exists in the Expo project — so the starters exclude it: ```json title="tsconfig.json" "exclude": ["node_modules", "supabase/functions"] ``` Without that, `npx tsc --noEmit` fails on every function. For editor support, open `supabase/functions/` with the Deno extension; `supabase/functions/deno.json` is already there for it. ## Scheduling Postgres runs the scheduler, via `pg_cron` calling the function over HTTP: ```sql select cron.schedule( 'nightly-digest', '0 3 * * *', $$ select net.http_post( url := 'https://<ref>.supabase.co/functions/v1/send-digest', headers := '{"Authorization": "Bearer <service-role-key>"}'::jsonb ); $$ ); ``` Enable `pg_cron` and `pg_net` under **Database → Extensions** first. ## Next - [Authentication](/docs/supabase/auth) - [Deployment](/docs/supabase/deployment) - [Supabase Edge Functions docs](https://supabase.com/docs/guides/functions) <!-- ---------------------------------------------------------------------- --> # Google Sign-In > Configure Google as an OAuth provider for the Supabase Auth starter — creating the OAuth client, the two redirect URLs that matter, and what to do when the browser comes back to nothing. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/supabase/google - Markdown: https://ui.ahmedbna.com/docs/supabase/google.md --- The starter's Google button works as soon as the provider is configured in your Supabase project. No code changes, no extra dependencies — it shares the browser PKCE path with Apple and GitHub. ## Create the OAuth client **1.** Open the Google Cloud console [console.cloud.google.com](https://console.cloud.google.com) → create or pick a project. **2.** Configure the consent screen **APIs & Services → OAuth consent screen**. Pick **External** unless this is a Workspace-only app. Fill in the app name, support email and developer contact. While the app is in **Testing**, only accounts you list under **Test users** can sign in — everyone else gets "access blocked". Publish before launch. **3.** Create the credentials **APIs & Services → Credentials → Create credentials → OAuth client ID**, type **Web application**. Web, not Android or iOS: the browser flow authenticates against Supabase's callback, which is a web endpoint. **4.** Add the authorized redirect URI Under **Authorized redirect URIs**, add exactly this, with your project reference: ``` https://<your-project-ref>.supabase.co/auth/v1/callback ``` **5.** Copy the client ID and secret into Supabase Supabase dashboard → **Authentication → Providers → Google** → enable, paste both, save. **6.** Allow-list your app's redirect URLs **Authentication → URL Configuration → Redirect URLs**. Both spellings, because Expo Go and a build produce different ones: ``` exp://localhost:8081 my-app:// ``` > Google's list is where Supabase may receive the callback — one URL, ending in > `/auth/v1/callback`. Supabase's list is where your app may receive it > afterwards. Configuring one and not the other is the usual cause of a sign-in > that gets all the way to "Continue" and then dies. ## Test it ```bash npx expo start ``` Tap **Continue with Google**. A browser sheet opens, you pick an account, and it closes; the session lands and the route guards move you into the app. ## What the starter does ```ts title="components/auth/oauth-buttons.tsx" const redirectTo = makeRedirectUri(); const { data } = await supabase.auth.signInWithOAuth({ provider: 'google', options: { redirectTo, skipBrowserRedirect: true }, }); const result = await openAuthSessionAsync(data.url, redirectTo); if (result.type === 'success') { const code = new URL(result.url).searchParams.get('code'); await supabase.auth.exchangeCodeForSession(code); } ``` Google hands back a name and picture in the ID token, and the `handle_new_user` trigger reads them, so a Google sign-up arrives with `display_name` and `avatar_url` already populated: ```sql title="supabase/migrations/0001_profiles.sql" coalesce( new.raw_user_meta_data ->> 'full_name', new.raw_user_meta_data ->> 'name' ), coalesce( new.raw_user_meta_data ->> 'avatar_url', new.raw_user_meta_data ->> 'picture' ) ``` ## When it does not work | What you see | Usually | | ----------------------------------------------- | ------------------------------------------------------------------- | | `redirect_uri_mismatch` from Google | The `/auth/v1/callback` URI is missing or has a typo | | Browser closes, nothing happens | Your app's scheme is not in Supabase's redirect allow-list | | "Access blocked: app not verified" | Consent screen still in Testing and this account is not a test user | | Works in Expo Go, fails in a build | `exp://localhost:8081` is allow-listed but `<scheme>://` is not | | `Unsupported provider: provider is not enabled` | Google is not switched on in Authentication → Providers | To see the redirect your device actually produces, log it: ```ts console.log(makeRedirectUri()); ``` Whatever that prints has to be in Supabase's list, exactly. ## Going native The browser flow works in Expo Go and needs no native modules. A native Google sheet needs `@react-native-google-signin/google-signin`, a development build, and separate iOS and web client IDs — plus care around the nonce, which Google's iOS SDK omits by default while Supabase expects one. See [authentication](/docs/supabase/auth#switching-to-native-sign-in). ## Next - [Apple Sign-In](/docs/supabase/apple) - [Email and SMTP](/docs/supabase/email) - [Supabase Google provider docs](https://supabase.com/docs/guides/auth/social-login/auth-google) <!-- ---------------------------------------------------------------------- --> # Apple Sign-In > Configure Apple as an OAuth provider for the Supabase Auth starter — the Services ID, the client secret you have to generate and rotate, and App Store review requirements. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/supabase/apple - Markdown: https://ui.ahmedbna.com/docs/supabase/apple.md --- Apple is the fiddliest provider to set up and the one App Store review cares about most. The starter's Apple button uses the same browser PKCE path as Google and GitHub, so once the provider is configured there is no code to write. > If your iOS app offers any third-party sign-in — Google, GitHub, anything — > Apple requires Sign in with Apple as an option. Shipping without it is a > rejection. You need a paid [Apple Developer](https://developer.apple.com) account. ## Configure Apple **1.** Create an App ID [developer.apple.com](https://developer.apple.com/account) → **Certificates, Identifiers & Profiles → Identifiers → +** → **App IDs** → **App**. Use your bundle identifier from `app.json` (`ios.bundleIdentifier`). Under **Capabilities**, enable **Sign In with Apple**. **2.** Create a Services ID **Identifiers → + → Services IDs**. This is a separate identifier from the App ID, and it is the one that becomes your `client_id`. Something like `com.yourcompany.myapp.signin`. Enable **Sign In with Apple**, then **Configure**: - **Primary App ID**: the App ID from the previous step - **Domains**: `<your-project-ref>.supabase.co` - **Return URLs**: `https://<your-project-ref>.supabase.co/auth/v1/callback` **3.** Create a signing key **Keys → +**. Name it, enable **Sign In with Apple**, configure it against your primary App ID, and register. Download the `.p8` file. **Apple lets you download it once.** Note the Key ID shown next to it, and your Team ID from the top right of the developer portal. **4.** Generate the client secret Apple does not issue a static secret. It is a JWT you sign with the `.p8` key, valid for at most six months. Supabase's dashboard has a generator: **Authentication → Providers → Apple → Generate a new secret**. Paste your Team ID, Key ID, Services ID and the contents of the `.p8`. **5.** Enable the provider Same page: enable Apple, set **Client ID** to the Services ID, paste the generated secret, save. For a native iOS sheet later, add your bundle identifier to **Additional client IDs** — a native `signInWithIdToken` presents the bundle ID rather than the Services ID. **6.** Allow-list your app's redirect URLs **Authentication → URL Configuration → Redirect URLs**: ``` exp://localhost:8081 my-app:// ``` > Six months, maximum. When it lapses, Apple sign-in stops for everyone with no > code change and no deploy to blame. Put a calendar reminder on it the day you > set it up — this catches people every time. ## Test it ```bash npx expo start ``` Tap **Continue with Apple**. The browser sheet opens; sign in with an Apple ID. ## What Apple sends back Less than the other providers, and only once. Apple returns the user's name on the **first** authorization and never again — and if the user picks **Hide My Email**, the address is a private relay that forwards to their real one. The `handle_new_user` trigger reads whatever is present: ```sql title="supabase/migrations/0001_profiles.sql" coalesce( new.raw_user_meta_data ->> 'display_name', new.raw_user_meta_data ->> 'full_name', new.raw_user_meta_data ->> 'name' ) ``` So an Apple sign-up often lands with a null `display_name`. That is fine here: the onboarding screen asks for one, which is part of why it exists. Do not build anything that depends on re-reading the name from Apple later. If you need it, capture it at first sign-in. ## When it does not work | What you see | Usually | | ---------------------------------------- | ---------------------------------------------------------------- | | `invalid_client` | Client ID is the App ID instead of the Services ID | | `invalid_client` after months of working | The six-month secret expired | | `invalid_request` / redirect rejected | Return URL in the Services ID does not match `/auth/v1/callback` | | Browser closes, nothing happens | Your app's scheme is not in Supabase's redirect allow-list | | Name is null after the first sign-in | Working as designed — Apple sends it once | To test the first-time flow again, revoke the app under **Settings → Apple ID → Sign-In & Security → Sign in with Apple** on the device, then delete the user in Supabase. ## Going native `expo-apple-authentication` gives the system sheet instead of a browser, which is what iOS users expect. It needs a development build: ```bash npx expo install expo-apple-authentication ``` ```tsx const credential = await AppleAuthentication.signInAsync({ requestedScopes: [ AppleAuthentication.AppleAuthenticationScope.FULL_NAME, AppleAuthentication.AppleAuthenticationScope.EMAIL, ], }); await supabase.auth.signInWithIdToken({ provider: 'apple', token: credential.identityToken!, }); ``` Add `"expo-apple-authentication"` to `plugins` in `app.json`, and add your bundle identifier to **Additional client IDs** in the Supabase provider settings. Keep the browser button for Android and web — `AppleAuthentication` is iOS-only. ## Next - [Google Sign-In](/docs/supabase/google) - [Authentication](/docs/supabase/auth) - [Supabase Apple provider docs](https://supabase.com/docs/guides/auth/social-login/auth-apple) <!-- ---------------------------------------------------------------------- --> # Email and SMTP > Configure email delivery for the Supabase Auth starter — custom SMTP, the templates magic links and OTP codes need, deep-link configuration, and testing the whole flow locally. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/supabase/email - Markdown: https://ui.ahmedbna.com/docs/supabase/email.md --- Three of the starter's six auth screens send email: sign-up confirmation, magic link, and password recovery. All three go through Supabase's email service, which needs configuring before it is usable for anything but development. > Supabase's default SMTP is rate-limited to a handful of messages per hour and > is explicitly documented as for testing only. Past that limit, sign-ups > silently stop arriving — no error in your app, nothing in the logs the user > can see. Configure your own provider before launch. ## Custom SMTP **1.** Get SMTP credentials [Resend](https://resend.com), [Postmark](https://postmarkapp.com), [SendGrid](https://sendgrid.com) and Amazon SES all work. You need a host, port, username and password. **2.** Verify your sending domain With the provider, not with Supabase — SPF and DKIM records on your DNS. Skipping this is the difference between the inbox and the spam folder, and auth email is exactly the kind of mail providers are suspicious of. **3.** Enter them in Supabase **Project Settings → Authentication → SMTP Settings** → enable custom SMTP. Set the sender address to something at your verified domain. **4.** Raise the rate limits **Authentication → Rate Limits**. The defaults are sized for the built-in service; with your own SMTP they are lower than you need. ## Templates **Authentication → Email Templates**. Each one is HTML with Go template variables. | Template | Sent when | Must contain | | -------------- | ------------------------------ | ---------------------------------------- | | Confirm signup | `signUp` with confirmations on | `{{ .ConfirmationURL }}` | | Magic Link | `signInWithOtp` | `{{ .ConfirmationURL }}`, `{{ .Token }}` | | Reset Password | `resetPasswordForEmail` | `{{ .ConfirmationURL }}` | | Change Email | `updateUser({ email })` | `{{ .ConfirmationURL }}` | ### The OTP code The starter's `/verify-otp` screen asks for a six-digit code, and the magic-link screen sends the user there. That code only exists in the email if the template includes `{{ .Token }}` — the default Magic Link template has the link but not the code. Add both, so either path works: ```html <h2>Sign in to My App</h2> <p><a href="{{ .ConfirmationURL }}">Click here to sign in</a></p> <p>Or enter this code: <strong>{{ .Token }}</strong></p> <p>It expires in an hour. If you did not request this, ignore this email.</p> ``` Offering both matters more on mobile than on web: a mail client that opens the link in an in-app browser can fail to hand control back to your app, and the code is the escape hatch. ## Deep links The link in an email has to come back into the app. Two things make that work. **The redirect passed from the client.** The starter uses `makeRedirectUri()`, which resolves to `exp://…` in Expo Go and `<scheme>://` in a build: ```ts title="app/(auth)/sign-up.tsx" const redirectTo = makeRedirectUri(); await supabase.auth.signUp({ email, password, options: { emailRedirectTo: redirectTo }, }); ``` Password recovery adds a path, so the user lands on the right screen: ```ts title="app/(auth)/forgot-password.tsx" const redirectTo = makeRedirectUri({ path: 'reset-password' }); await supabase.auth.resetPasswordForEmail(email, { redirectTo }); ``` **The allow-list.** Supabase rejects any redirect target not in **Authentication → URL Configuration → Redirect URLs**: ``` exp://localhost:8081 exp://localhost:8081/--/reset-password my-app:// my-app://reset-password ``` The `/--/` spelling is how Expo Go encodes a path. Include it or password recovery works in a build and not in development. Once the app has the URL, `providers/auth-provider.tsx` exchanges it for a session — see [authentication](/docs/supabase/auth#deep-links). ## Confirmations on or off `supabase/config.toml` has confirmations **off** locally: ```toml title="supabase/config.toml" [auth.email] enable_confirmations = false ``` So `signUp` returns a session immediately and you are not clicking through a mail catcher on every reload. The hosted project has them **on** by default, which is right for production — and it changes what `signUp` returns: ```ts title="app/(auth)/sign-up.tsx" if (!data.session) { toast.success('Check your email', `We sent a confirmation link to ${email}.`); router.replace('/sign-in'); } ``` That branch is why the sign-up screen does not look broken when confirmations are on. Keep it. ## Testing locally `supabase start` includes [Inbucket](http://localhost:54324), which catches every message the stack sends instead of delivering it. The whole flow — sign-up, magic link, OTP, recovery — is testable with no SMTP configured at all. ```bash npx supabase start # then open http://localhost:54324 ``` Emails appear immediately, with the rendered template, so it is also the fastest way to check that `{{ .Token }}` is where you think it is. ## Rate limits and abuse Email endpoints are the ones people hammer. Supabase applies per-hour limits per address and per IP, and the starter surfaces the resulting error in a toast rather than swallowing it. Two behaviours worth knowing: - `resetPasswordForEmail` returns success whether or not the address has an account, so the form cannot be used to discover who has one. The starter's confirmation copy says "if an account exists" for the same reason. - `signInWithOtp` in the starter passes `shouldCreateUser: false`, so the magic link screen cannot create accounts by accident. Flip it if you want magic links to double as sign-up. ## When it does not work | What you see | Usually | | ----------------------------------------- | --------------------------------------------------------- | | No email at all, no error | Built-in SMTP rate limit — configure your own | | Email arrives in spam | Sending domain has no SPF/DKIM | | Link opens a browser, app never opens | Scheme missing from the redirect allow-list | | `/verify-otp` has no code to enter | Template lacks `{{ .Token }}` | | Recovery works in a build, not in Expo Go | `exp://localhost:8081/--/reset-password` not allow-listed | | "Email link is invalid or has expired" | Link already used, or older than the configured expiry | ## Next - [Authentication](/docs/supabase/auth) - [Troubleshooting](/docs/supabase/troubleshooting) - [Supabase SMTP docs](https://supabase.com/docs/guides/auth/auth-smtp) <!-- ---------------------------------------------------------------------- --> # Deployment > Ship a Supabase-backed Expo app — EAS build profiles and environment variables, the GitHub Actions workflow both starters include, migration safety, and the production checklist. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/supabase/deployment - Markdown: https://ui.ahmedbna.com/docs/supabase/deployment.md --- Two things deploy independently: your Supabase project (migrations, functions, configuration) and your app (EAS build, or an over-the-air update). The starters ship a GitHub Actions workflow that does both. ## Environment variables `EXPO_PUBLIC_` variables are **inlined into the JavaScript bundle at build time**. They are not read at runtime, and they are not secret. That means: - Changing one requires a new build or OTA update, not a server restart. - Anyone with your app has them. That is fine for the publishable key, whose privileges are exactly what your RLS policies grant, and never fine for a secret key. For EAS, put them on the build profile rather than in `.env.local`, which is gitignored and not uploaded: ```json title="eas.json" { "build": { "preview": { "distribution": "internal", "channel": "preview", "env": { "EXPO_PUBLIC_SUPABASE_URL": "https://staging-ref.supabase.co", "EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY": "sb_publishable_staging" } }, "production": { "autoIncrement": true, "channel": "production", "env": { "EXPO_PUBLIC_SUPABASE_URL": "https://prod-ref.supabase.co", "EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY": "sb_publishable_prod" } } } } ``` Or manage them as EAS environment variables, which keeps them out of the repo: ```bash eas env:create --name EXPO_PUBLIC_SUPABASE_URL --value https://prod-ref.supabase.co --environment production ``` > Not separate schemas in one project. Migrations, auth settings, rate limits > and provider credentials are all project-level, and you want to get them wrong > somewhere that is not production. ## Building ```bash eas build --platform all --profile preview eas build --platform all --profile production eas submit --platform ios --profile production ``` The shipped `development` profile has `developmentClient: true` — that is the one to use if you switch to native OAuth, which does not work in Expo Go. ## Over-the-air updates ```bash eas update --branch production --message "Fix the reset-password redirect" ``` OTA updates ship JavaScript. They cannot change native code, and that includes anything a config plugin touches — adding `expo-apple-authentication`, changing `scheme`, adding a permission string all need a new build. Changing `scheme` also means updating your Supabase redirect allow-list, or OAuth breaks for everyone. ## The shipped workflow `.github/workflows/ci.yml` in both starters has four jobs. **`verify`** — `tsc --noEmit`, `expo lint`, `jest`. Runs on every PR, needs no database because `lib/database.types.ts` is checked in. **`types-are-current`** — spins up a local Postgres, applies every migration, regenerates the types and fails if the checked-in file differs: ```yaml title=".github/workflows/ci.yml" - run: supabase db start - run: supabase gen types typescript --local > lib/database.types.ts - name: No uncommitted type changes run: git diff --exit-code lib/database.types.ts ``` This is the job that stops a migration landing without its types — the drift is otherwise invisible until someone's editor disagrees with production. **`deploy`** — on merge to `main`, links the project, pushes migrations and deploys functions. **`build`** — kicks off an EAS build. ### Secrets it needs | Secret | From | | ----------------------- | -------------------------------------------------------------------------- | | `SUPABASE_ACCESS_TOKEN` | [Account → Access Tokens](https://supabase.com/dashboard/account/tokens) | | `SUPABASE_PROJECT_ID` | Your project reference | | `SUPABASE_DB_PASSWORD` | Set when you created the project | | `EXPO_TOKEN` | [expo.dev/settings/access-tokens](https://expo.dev/settings/access-tokens) | ## Migration safety `supabase db push` applies anything not in the remote migration history. It does not roll back, and it runs against a live database. The usual rules: - **Add columns nullable**, backfill in a separate migration, then add the constraint. A `not null` column with a default rewrites the table and takes a lock. - **Do not drop anything the deployed app still reads.** Deploy the app that stops reading it first, then drop it. - **Deploy migrations before the app that needs them.** A new column with old clients is harmless; a new client without its column is a crash. That ordering matters more here than with a web app, because your users decide when they update. Assume old versions are live for weeks. ## Production checklist **1.** Turn email confirmations on `supabase/config.toml` has them off for local development. Check the hosted project's setting under **Authentication → Providers → Email**. **2.** Configure your own SMTP The built-in service is rate-limited to a handful of messages an hour. See [email](/docs/supabase/email). **3.** Allow-list the production redirect URLs Your production scheme, and the `reset-password` path. If `scheme` in `app.json` changed, this changed. **4.** Confirm no secret key is in the bundle ```bash grep -r "sb_secret_" . --exclude-dir=node_modules ``` **5.** Re-read every RLS policy as an attacker Assume the publishable key is public, because it is. Two accounts, and check that one cannot see the other's rows or files. **6.** Add indexes for the columns policies filter on Every policy filtering on `user_id` makes every query filter on it. See [database](/docs/supabase/database#indexes-follow-policies). **7.** Raise the auth rate limits **Authentication → Rate Limits**. The defaults are sized for the built-in email service. **8.** Turn on Point in Time Recovery Daily backups are the default on paid plans; PITR is what you want the day someone runs the wrong `delete`. **9.** Check the function JWT settings Anything user-specific must deploy **without** `--no-verify-jwt`. ## Monitoring The dashboard's **Logs** section covers Postgres, PostgREST, auth, storage and edge functions. **Reports** shows query performance — the slow query list is usually where a missing RLS index shows up. For the app, `expo-updates` and EAS Insights cover adoption and crashes. ## Next - [Database and RLS](/docs/supabase/database) - [Troubleshooting](/docs/supabase/troubleshooting) - [Expo EAS Build docs](https://docs.expo.dev/build/introduction/) <!-- ---------------------------------------------------------------------- --> # Troubleshooting and FAQ > The failures the Supabase starters actually produce — silent realtime, rejected redirects, empty queries, sessions that do not survive a relaunch — plus common questions and a Convex to Supabase migration guide. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/supabase/troubleshooting - Markdown: https://ui.ahmedbna.com/docs/supabase/troubleshooting.md --- ## Auth ### OAuth opens the browser, then nothing happens Your app's redirect URL is not in the allow-list. **Authentication → URL Configuration → Redirect URLs** needs both spellings, because Expo Go and a build produce different ones: ``` exp://localhost:8081 my-app:// ``` To see exactly what your device produces: ```ts import { makeRedirectUri } from 'expo-auth-session'; console.log(makeRedirectUri()); ``` That string, verbatim, has to be on the list. ### Works in Expo Go, breaks in a build Same cause, other direction — `exp://localhost:8081` is allow-listed and `<scheme>://` is not. It also happens after changing `scheme` in `app.json`, which the CLI sets to your project name at scaffold time. ### `redirect_uri_mismatch` from Google or Apple A different list. The provider needs Supabase's callback: ``` https://<your-project-ref>.supabase.co/auth/v1/callback ``` Supabase's list is where your _app_ may receive the redirect afterwards. Both have to be right. See [Google](/docs/supabase/google) and [Apple](/docs/supabase/apple). ### The user is signed out on every relaunch Session persistence is failing. Almost always the storage adapter: `expo-secure-store` refuses values over 2048 bytes and a real session is several kilobytes. This is why the starter uses `LargeSecureStore`. It is a nasty one to catch because a test account with no metadata can fit under the limit — so it works all through development and breaks on your first real user. See [authentication](/docs/supabase/auth#session-storage). ### Requests start failing after the app has been backgrounded The access token expired and the refresh timer did not run — iOS suspends timers in backgrounded apps. The fix is in `lib/supabase.ts`: ```ts AppState.addEventListener('change', (state) => { if (state === 'active') supabase.auth.startAutoRefresh(); else supabase.auth.stopAutoRefresh(); }); ``` ### Sign-in hangs with no error `detectSessionInUrl` is `true` on native. There is no URL to parse, so the client waits for a callback that never arrives. It must be `Platform.OS === 'web'`. ### `Email not confirmed` Confirmations are on and the link has not been opened. That is correct behaviour: `supabase/config.toml` turns them off locally for convenience, and the hosted project has them on. The sign-up screen handles it — ```ts title="app/(auth)/sign-up.tsx" if (!data.session) { toast.success('Check your email', `We sent a confirmation link to ${email}.`); router.replace('/sign-in'); } ``` — so if you removed that branch, put it back. ### The OTP screen has no code to enter Your Magic Link email template has `{{ .ConfirmationURL }}` but not `{{ .Token }}`. The default template omits the code. See [email](/docs/supabase/email#the-otp-code). ### No email arrives at all, and no error The built-in SMTP rate limit, which is a handful of messages an hour. Configure your own provider. ### The onboarding screen flashes for existing users The guard is `!profile?.onboarded` instead of `profile?.onboarded === false`. The profile row is created by a database trigger and is briefly `null`, which the loose check reads as "not onboarded". ## Database ### A query returns an empty array with no error RLS. An empty result is what a policy denial looks like from the client — there is no permission error, because the row simply is not visible. Check in order: **1.** Is there a policy for this operation and role at all? A table with RLS enabled and no matching policy returns nothing. `to authenticated` policies do not apply to an anonymous caller. **2.** Is the user actually signed in? `auth.uid()` is `null` otherwise, and every `auth.uid() = user_id` comparison fails. **3.** Try it in the SQL editor as that user ```sql set local role authenticated; set local request.jwt.claims = '{"sub": "<user-id>"}'; select * from public.tasks; reset role; ``` ### An insert fails with "new row violates row-level security policy" The `with check` expression on your insert policy is false. Usually the client sent a `user_id` that is not `auth.uid()` — which is the policy doing its job. ### Everything works, and anyone can read the table RLS is not enabled. Policies on a table without it do nothing: ```sql alter table public.your_table enable row level security; ``` New tables have it off. This is the most consequential default in Postgres. ### Queries got slow as the table grew Every policy that filters on a column makes every query filter on it. Without an index that is a sequential scan: ```sql create index tasks_user_id_created_at_idx on public.tasks (user_id, created_at desc); ``` ### `db push` says the migration is already applied The remote history and your local files disagree — usually because someone changed the schema in Studio. Reconcile the history, then capture the Studio changes as a real migration: ```bash npx supabase migration repair --status applied <version> npx supabase db diff -f describe_what_changed ``` ### TypeScript does not know about a column I added Regenerate: `npm run db:types`. The shipped CI has a job that fails when the checked-in types drift from the migrations. ## Realtime ### The subscription connects but never fires The table is not in the publication: ```sql alter publication supabase_realtime add table public.tasks; ``` Check what is: ```sql select tablename from pg_publication_tables where pubname = 'supabase_realtime'; ``` ### DELETE events arrive with no data `replica identity full` is missing. Postgres sends only the primary key otherwise. ### Rows appear twice An optimistic insert plus the realtime echo. Deduplicate by id — that is what `applyChange` in `lib/realtime.ts` does. Or: the channel was subscribed twice because the effect cleanup does not call `supabase.removeChannel(channel)`. ### The list is stale after the app was backgrounded Events do not queue while the socket is closed. Refetch on reconnect: ```ts .subscribe((status) => { if (status === 'SUBSCRIBED') load(); }); ``` ## Storage ### Upload fails with a 403 The bucket has no insert policy, or the object path does not match it. The owner-scoped policies key off the first path segment, so the path has to start with the user's id: ```ts const path = `${user.id}/avatar.${extension}`; ``` ### Upload fails with `mime type not supported` `contentType` was not set, so it defaulted to `application/octet-stream`, which is not in the bucket's `allowed_mime_types`. ### Large images fail on Android Base64. Use `fetch(uri).then((r) => r.arrayBuffer())` — base64 is a third larger and has to be held in memory whole. ### The new avatar does not appear The path is stable, so the URL is stable, so every cache in between keeps serving the old image. Add a cache-buster: ```ts return `${publicUrl}?v=${Date.now()}`; ``` ## Edge functions ### `tsc --noEmit` fails on files under `supabase/functions` They are Deno. Exclude them: ```json title="tsconfig.json" "exclude": ["node_modules", "supabase/functions"] ``` ### `invoke` returns a 401 The function was deployed with JWT verification on and the caller has no session, or the token expired. ### The function's queries return nothing It is running as `anon` and RLS is filtering everything. Forward the caller's header: ```ts { global: { headers: { Authorization: req.headers.get('Authorization')!; } } } ``` ## FAQ **Is the publishable key safe to ship?** Yes — that is what it is for. It grants exactly what your RLS policies allow the `anon` role. Which is why those policies are the thing to review. A `sb_secret_…` key is never safe to ship; it bypasses RLS entirely. **Do I need the Supabase CLI?** No. The scaffold works without it and prints the commands instead. You need it to apply migrations, generate types or deploy functions — so in practice, yes. **Do I need Docker?** Only for `supabase start`. Linking a hosted project covers migrations, types and functions with no Docker at all. **Can I use this in Expo Go?** Yes, including OAuth — that is why the starter uses browser PKCE rather than native sign-in SDKs. **How do I add a table?** `npx supabase migration new <name>`, write the DDL plus `enable row level security` and its policies, `npm run db:push`, `npm run db:types`. See [database](/docs/supabase/database). **How do I add a sign-in provider?** Configure it in the dashboard, then add a line to the `PROVIDERS` array in `components/auth/oauth-buttons.tsx`. All of them share one code path. **Why is there no `callback` route?** The deep link can arrive while any screen is mounted, and on a cold start before the router has settled. `providers/auth-provider.tsx` handles it centrally so none of that matters. **Can I use Supabase with the Convex starter?** Pick one. They are both backends; running both means two sources of truth. ## Migrating from Convex Both are TypeScript-first backends with realtime queries, so the shapes map closely. | Convex | Supabase | | ---------------------------- | --------------------------------------------------- | | `convex/schema.ts` | `supabase/migrations/*.sql` | | `defineTable({...})` | `create table …` | | Function-level auth checks | RLS policies, enforced by Postgres | | `useQuery(api.tasks.list)` | `select` + a `postgres_changes` subscription | | `useMutation(api.tasks.add)` | `supabase.from('tasks').insert(...)` | | `query` / `mutation` | Direct queries; edge functions for privileged work | | `action` | Edge function | | `ctx.auth.getUserIdentity()` | `auth.uid()` in a policy, `getUser()` in a function | | `_generated/api.d.ts` | `lib/database.types.ts` from `supabase gen types` | | `ConvexAuthProvider` | `providers/auth-provider.tsx` | The real shift is where authorization lives. In Convex you check permissions inside each function; in Supabase the client talks to the database directly, so the check has to be a policy. A Convex function that forgot its check leaks through one endpoint — a Postgres table that forgot RLS leaks entirely. Suggested order: **1.** Translate the schema One migration per Convex table, with RLS enabled and policies written at the same time. Never as a follow-up. **2.** Move reads Each `useQuery` becomes a `select` plus a subscription. `hooks/useTasks.ts` is the pattern. **3.** Move writes Each `useMutation` becomes a direct query. The authorization that lived in the function body is now the policy's `with check`. **4.** Move actions Anything calling a third-party API or needing a secret becomes an edge function. **5.** Move auth last `ConvexAuthProvider` → `AuthProvider`. Users do not transfer; plan a re-registration or a scripted import via the admin API. ## Still stuck - [Report an issue](https://github.com/ahmedbna/ui/issues) - [Supabase docs](https://supabase.com/docs) · [Supabase GitHub discussions](https://github.com/orgs/supabase/discussions) <!-- ---------------------------------------------------------------------- --> # Firebase > A React Native starter with BNA UI components and a Firebase backend — Cloud Firestore, Cloud Storage, Authentication and security rules with tests, with or without sign-in. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/firebase - Markdown: https://ui.ahmedbna.com/docs/firebase.md --- [Firebase](https://firebase.google.com) is Google's app platform: a document database that streams changes to every listener, object storage, an authentication service with a dozen providers, and a rules language that runs on Google's servers rather than in your app. BNA UI ships two Firebase scaffolds: one with a backend and no sign-in, one with authentication and the screens already built. Both use the **`firebase` JS SDK**, so they run in Expo Go — no config plugin, no `google-services.json`, no development build to get started. [Expo + Firebase](/docs/installation/firebase) — Firestore, Cloud Storage, rules and rules tests. No sign-in. [Expo + Firebase + Auth](/docs/installation/firebase-auth) — Password, Google and Apple, with owner-scoped documents. ## The auth starter Built around the same principles as the rest of BNA UI: - **Open Code:** the authentication screens, the hooks and the security rules are copied into your project, not imported from a package. - **Mobile-First:** flows designed for React Native, with the persisted user encrypted in the platform keychain. - **Cross-Platform:** iOS, Android and web, from one code path. - **Secure by default:** every document scoped to `request.auth.uid`, enforced by Google — not by what the client asks for. ### Sign-in methods | Method | Mechanism | Ships with a screen | Expo Go | | ------------------ | ---------------------------------------------------- | ---------------------- | ------- | | Email + password | `signInWithEmailAndPassword` | Yes | Yes | | Password reset | `sendPasswordResetEmail` | Yes | Yes | | Email verification | `sendEmailVerification` | Card in Settings | Yes | | Google | `expo-auth-session` → `signInWithCredential` | Yes | **No** | | Apple | `expo-apple-authentication` → `signInWithCredential` | Yes | **No** | | Email link | `sendSignInLinkToEmail` | Yes, hidden by default | **No** | > There is no email OTP — Firebase Authentication has no six-digit email code at > all, only SMS. And there is no GitHub: obtaining a GitHub access token > requires a client secret, which cannot ship in an app bundle. Supabase does > that exchange on its own servers; Firebase expects you to run one. ### What lands in your project ``` lib/ ├── firebase.ts the client — app, auth, Firestore, Storage ├── large-secure-store.ts AES-256 persistence for the auth record ├── documents.ts snapshot → plain object, as pure functions ├── auth-link.ts the action-link parser, as a pure function └── errors.ts Firebase error code → prose providers/auth-provider.tsx user, profile, deep links app/(auth)/ five screens app/(onboarding)/ intro carousel + profile setup firestore.rules owner-only, with no catch-all match storage.rules avatars/<uid>/… and files/<uid>/… rules-tests/ the rules, executed against the emulator ``` ### The two layers of access control The route guards in `app/_layout.tsx` decide what renders: ```tsx title="app/_layout.tsx" <Stack.Protected guard={!signedIn}> <Stack.Screen name='(auth)' /> </Stack.Protected> <Stack.Protected guard={signedIn && !needsOnboarding}> <Stack.Screen name='(tabs)' /> </Stack.Protected> ``` The security rules decide what Firestore will actually return: ``` match /tasks/{taskId} { allow read: if isOwner(resource.data.ownerId); } ``` Only the second is a security boundary. The first is there so users are not looking at empty screens. ### The rule that catches everyone **Firestore evaluates a read rule against the query, not against the documents it would return.** It refuses any query it cannot prove in advance is limited to documents the rule allows: ```ts // permission-denied, even signed in query(collection(db, 'tasks'), orderBy('createdAt', 'desc')); // fine query(collection(db, 'tasks'), where('ownerId', '==', uid)); ``` A Postgres RLS policy does the opposite: the filter is optional and the policy quietly narrows the result. If you are moving between the Supabase and Firebase starters, this is the difference that will bite you. [Security rules](/docs/firebase/rules) covers it properly. ### Reading the signed-in user ```tsx import { useAuth } from '@/providers/auth-provider'; const { user, profile, loading, signOut } = useAuth(); ``` `profile` is the `users/{uid}` document, kept current over an `onSnapshot` subscription. `user` is the Firebase `User` record. There is no `session` — Firebase has no session object. ## Environment | Variable | Where it lives | Set by | Used for | | --------------------------------- | -------------- | ----------------- | ------------------- | | `EXPO_PUBLIC_FIREBASE_API_KEY` | `.env.local` | `bna-ui firebase` | Building the client | | `EXPO_PUBLIC_FIREBASE_PROJECT_ID` | `.env.local` | `bna-ui firebase` | Building the client | | `EXPO_PUBLIC_FIREBASE_APP_ID` | `.env.local` | `bna-ui firebase` | Building the client | | `EXPO_PUBLIC_GOOGLE_*_CLIENT_ID` | `.env.local` | You | Google sign-in | | `EXPO_PUBLIC_FIREBASE_LINK_URL` | `.env.local` | You | Email links | | `GOOGLE_APPLICATION_CREDENTIALS` | CI | You | Deploying rules | > Unlike a Supabase key, these values identify your project rather than granting > access to it — Google publishes them in the console for you to paste into a > web page. What actually guards your data is `firestore.rules` and > `storage.rules`. What must never appear in `.env.local` is a service account > JSON, an Admin SDK private key, or an OAuth client secret. Provider credentials — the Apple team key, SMTP settings, OAuth secrets — live in the Firebase console, not in any file in your repository. ## Guides - [Firestore](/docs/firebase/firestore) — the data model, queries, indexes and what `serverTimestamp()` does locally - [Security rules](/docs/firebase/rules) — the query-scoping rule, the Storage `delete` trap, and how to test both - [Authentication](/docs/firebase/auth) — persistence, route guards, deep links and account deletion - [Google](/docs/firebase/google) · [Apple](/docs/firebase/apple) · [Email and links](/docs/firebase/email) - [Realtime](/docs/firebase/realtime) · [Storage](/docs/firebase/storage) - [Deployment](/docs/firebase/deployment) — EAS, CI/CD, and the production checklist - [Troubleshooting](/docs/firebase/troubleshooting) — and a Supabase → Firebase migration guide ## Learn more - [Firebase documentation](https://firebase.google.com/docs) - [Report an issue](https://github.com/ahmedbna/ui/issues) <!-- ---------------------------------------------------------------------- --> # Authentication > How the Firebase auth starter persists a session, guards routes, handles deep links, creates profiles and deletes accounts. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/firebase/auth - Markdown: https://ui.ahmedbna.com/docs/firebase/auth.md --- The auth starter uses Firebase Authentication through the `firebase` JS SDK. Email and password work in Expo Go; Google and Apple need a development build. ## Session persistence Firebase writes one JSON blob per user under `firebase:authUser:<apiKey>:[DEFAULT]` — uid, email, `photoURL`, the whole `providerData` array, and a `stsTokenManager` holding the access and refresh tokens. A bare email/password account is around 1.5 KB. Add a Google identity with a long CDN `photoURL` and it goes past `expo-secure-store`'s **2048-byte** ceiling. That failure mode is worth naming precisely: **it works with your test account and breaks when a real user signs in with Google.** So `LargeSecureStore` puts a 256-bit AES key in the Keychain (or the EncryptedSharedPreferences-backed Keystore on Android) and the ciphertext in AsyncStorage, which has no size limit: ```ts title="lib/firebase.ts" initializeAuth(app, { persistence: getReactNativePersistence(new LargeSecureStore()), }); ``` The class already implements Firebase's `ReactNativeAsyncStorage` interface — `getItem` / `setItem` / `removeItem`, all promise-returning — so no adapter sits between them. ### The typed lookup ```ts title="lib/firebase.ts" const getReactNativePersistence = ( firebaseAuth as unknown as { getReactNativePersistence?: (s: ReactNativeAsyncStorage) => Persistence; } ).getReactNativePersistence; ``` This looks like a hack and is not. `getReactNativePersistence` is declared only in `@firebase/auth/dist/rn/index.rn.d.ts`. Metro picks that build on iOS and Android — the package's `exports` map has a `react-native` condition and `@expo/metro-config` enables it — but TypeScript resolves the `types` condition, which is listed first and points at the browser build. So the symbol is real at runtime and invisible to the compiler, and ```ts import { getReactNativePersistence } from 'firebase/auth'; // does not compile ``` If it is ever genuinely absent, `lib/firebase.ts` **throws**: > The silent fallback is in-memory persistence, which signs every user out on > relaunch. That surfaces as "sessions randomly expire" and takes a day to > trace. A loud error at startup takes a minute. ### No AppState listener A Supabase project needs one, because `supabase-js` refreshes tokens on a JS timer that iOS suspends in the background. Firebase refreshes proactively and again lazily inside `getIdToken()`, and Firestore and Storage both pull their token through the same `Auth` instance. There is nothing to wire up, and `lib/firebase.ts` has a comment saying so — otherwise its absence reads as an oversight. ## The provider ```tsx const { user, profile, loading, signOut } = useAuth(); ``` There is no `session` field. Firebase has no session object; the `User` carries `getIdToken()`. `loading` starts `true` and flips in the first `onAuthStateChanged` callback, which fires exactly once after the SDK has finished reading persistence: ```ts title="providers/auth-provider.tsx" return onAuthStateChanged(auth, (nextUser) => { setUser(nextUser); setLoading(false); }); ``` Everything renders a spinner until then, so a returning user never sees the sign-in screen flash before their session loads. There is no `getSession()` equivalent and none is needed. ## Route guards ```tsx title="app/_layout.tsx" <Stack.Protected guard={!signedIn}> <Stack.Screen name='(auth)' /> </Stack.Protected> <Stack.Protected guard={needsOnboarding}> <Stack.Screen name='(onboarding)' /> </Stack.Protected> <Stack.Protected guard={signedIn && !needsOnboarding}> <Stack.Screen name='(tabs)' /> </Stack.Protected> ``` `Stack.Protected` unmounts the screens whose guard is false and redirects away from them, so this is not a cosmetic hide — with no signed-in user there is no navigation path into `(tabs)` at all, deep link or otherwise. The guards are mutually exclusive, so exactly one group is mounted. They are still a **convenience, not the boundary**. [`firestore.rules`](/docs/firebase/rules) says the same thing server-side, where a modified client cannot argue. No screen calls `router.replace('/')` after signing in. The user lands, `onAuthStateChanged` fires, and the guards swap the groups. ## Profiles There is no Cloud Function creating `users/{uid}` — those require the paid Blaze plan. The provider writes it from its **snapshot listener**: ```ts title="providers/auth-provider.tsx" return onSnapshot(doc(db, 'users', userId), async (snapshot) => { if (!snapshot.exists()) { await ensureProfile(auth.currentUser); return; } setProfile(profileFromDoc(snapshot)); }); ``` Driving it off the listener rather than a one-shot write after sign-up makes it self-healing: if the first attempt never landed, the next launch fixes it. The consequence for the guards: ```ts const needsOnboarding = signedIn && profile?.onboarded === false; ``` `profile` being `null` means **"not created yet"**, never "not onboarded". Treating null as `false` would flash the onboarding flow at a returning user on a slow connection. `profile` is also derived (`user ? profile : null`) so a signed-out render can never briefly expose the previous user's document. ## Deep links Password resets, email verification and email-link sign-in all come back as a deep link. The handler lives on the provider rather than a `callback` route because a link can arrive while any screen is mounted, and on a cold start before the router has settled anywhere: ```ts title="providers/auth-provider.tsx" if (isSignInWithEmailLink(auth, url)) { const email = await SecureStore.getItemAsync(PENDING_EMAIL_KEY); if (!email) return; // opened on another device — a dead end by design await signInWithEmailLink(auth, email, url); return; } const link = parseAuthLink(url); if (link?.kind === 'resetPassword') { router.push({ pathname: '/reset-password', params: { oobCode: link.oobCode }, }); } if (link?.kind === 'verifyEmail') { await applyActionCode(auth, link.oobCode); await auth.currentUser?.reload(); // emailVerified is cached on the User } ``` `isSignInWithEmailLink` is asked first because the SDK is the authority on those. Everything else is parsed by `lib/auth-link.ts`, a pure function with its own tests — deep-link bugs otherwise only surface on a real device with a real email. > `signInWithEmailLink` needs the address back, and prompting "which address was > this?" when a link is opened is a phishing pattern. So the address is stashed > in SecureStore when the link is sent, and a link opened elsewhere simply does > nothing. ## Sign-up Firebase signs a new user in **immediately, verified or not**. There is no "check your email before you continue" state the way there is with Supabase's email confirmation: ```ts title="app/(auth)/sign-up.tsx" const { user } = await createUserWithEmailAndPassword(auth, email, password); if (name) await updateProfile(user, { displayName: name }); await sendEmailVerification(user).catch(() => {}); ``` So the route guards take over straight away, and an unverified-email card in Settings does the nagging. To make verification mandatory, gate the `(tabs)` guard on `user.emailVerified` as well as on `user`. ### Password rules `describePasswordProblem` asks for 8 characters with mixed case and a digit. Firebase's own floor is **six characters and nothing else** — everything past that is the app's opinion, enforced only in the client. Configure a policy under Authentication → Settings → Password policy (Identity Platform) to make it real, and keep the two in step. ### Enumeration protection Firebase returns `auth/invalid-credential` for both a wrong password and an unknown address when email enumeration protection is on — the default for projects created since September 2023. `lib/errors.ts` maps it to one message that is true either way, and a test asserts it never says "no such account": ```ts 'invalid-credential': 'That email and password do not match an account.', ``` ## Account deletion Firebase lets a user delete their own account from the client, so unlike the Supabase starter no server function is needed. But Firestore has no `ON DELETE CASCADE`, so the data has to be walked by hand: ```ts title="hooks/useDeleteAccount.ts" await deleteTasks(user.uid); // batched, 500 at a time await deleteAvatars(user.uid); await deleteDoc(doc(db, 'users', user.uid)); await deleteUser(user); // last ``` The order matters. A failure part-way leaves a **usable account** the user can retry with; the reverse order would leave documents nobody can ever reach or delete, because the rules key on a uid that no longer exists. > It runs on the user's device, so a crash mid-way leaves orphans. The robust > version is server-side: Firebase's official "Delete User Data" extension, or a > Cloud Function on the `user.delete` trigger. Neither ships here because Cloud > Functions require the paid Blaze plan. `deleteUser` also throws `auth/requires-recent-login` on a token older than a few minutes, which is what the password prompt in the confirm dialog is for. A federated user has no password to type, so the dialog asks them to sign out and back in instead. ## Learn more - [Google](/docs/firebase/google) · [Apple](/docs/firebase/apple) · [Email and links](/docs/firebase/email) - [Security rules](/docs/firebase/rules) - [Firebase Auth documentation](https://firebase.google.com/docs/auth) <!-- ---------------------------------------------------------------------- --> # Firestore > The data model both starters use, how queries and composite indexes work, and the serverTimestamp behaviour that crashes naive mappers. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/firebase/firestore - Markdown: https://ui.ahmedbna.com/docs/firebase/firestore.md --- Cloud Firestore is a document database. There is no schema, no migrations and no SQL — a collection holds documents, a document holds fields, and every read can be a live subscription. ## The data model ``` tasks/{taskId} text string, 1..500 isComplete boolean createdAt Timestamp — always serverTimestamp() searchTokens string[] — tokenize(text) ownerId string — the uid (auth starter only) users/{uid} — auth starter only email string | null displayName string | null photoURL string | null onboarded boolean createdAt Timestamp updatedAt Timestamp ``` Two deliberate choices: - **Fields are camelCase**, not `is_complete` / `created_at`. Snake case in a Firestore document is SQL cosplay; `photoURL` in particular mirrors the Firebase `User` field it is kept in step with. - **`tasks` is top-level**, not `users/{uid}/tasks/{id}`. A subcollection would make the rules trivial and drop an index, but it pushes search onto `collectionGroup()` and makes the model harder to compare with the Supabase starter's table. Swap it if your access pattern is always per-user. The `users` document id **is** the uid. That is what lets `firestore.rules` say `request.auth.uid == userId` without reading the document first. ## serverTimestamp is null locally This is the single most common Firestore crash, and it is not an edge case: ```ts // 💥 on the first task anyone adds const createdAt = doc.data().createdAt.toMillis(); ``` `serverTimestamp()` is a sentinel. Firestore applies your write to the local cache immediately and delivers a snapshot for it **before** the server has resolved the timestamp — so `createdAt` is `null` in that first echo. Every single write goes through this state. `lib/documents.ts` handles it, and the tests assert it: ```ts title="lib/documents.ts" export function taskFromDoc(doc: QueryDocumentSnapshot<DocumentData>): Task { return { id: doc.id, // never data().id — Firestore does not store it in the document createdAt: millisOf(doc.data().createdAt), // null while pending pending: doc.metadata.hasPendingWrites, // … }; } ``` `byNewest` then sorts a null `createdAt` **first**: the thing you just typed is the newest thing you did, and sorting it to the bottom makes the app look like it ignored you. > It uses `import type` only, so `__tests__/documents.test.ts` runs with no > mocks, no emulator and no environment variables. That is also why the > Timestamp check is structural rather than `instanceof Timestamp`, which is > unreliable the moment two copies of the SDK end up in a bundle. ## Queries and indexes Firestore builds single-field indexes automatically. Anything that **combines** fields needs a composite index declared in `firestore.indexes.json`: | Query | Composite index? | | --------------------------------------------------------------------- | ----------------------------------- | | `orderBy('createdAt', 'desc')` | No — automatic | | `where('ownerId', '==', uid)` + `orderBy('createdAt')` | **Yes** | | `where('searchTokens', 'array-contains', t)` + `orderBy('createdAt')` | **Yes** | | `getCountFromServer(collection(db, 'tasks'))` | No | | `doc(db, 'users', uid)` | No — document reads are not queries | If you add a query Firestore cannot serve, the error carries a console URL that creates exactly the right index. `lib/errors.ts` passes that message through verbatim rather than replacing it with friendly prose, because it is the most useful thing the SDK ever tells you: ```ts title="lib/errors.ts" if (isMissingIndex(error)) { return ( 'This query needs a composite index. Firestore generated one for you:\n\n' + String(error.message) + '\n\nAdd it to firestore.indexes.json…' ); } ``` Paste the result into `firestore.indexes.json` and run `npm run deploy:indexes` so it lives in your repository, rather than clicking through in production. ## Search Firestore has no `LIKE`, no substring matching and no full-text index. The standard workaround is to write searchable words alongside the document: ```ts title="lib/documents.ts" export function tokenize(text: string): string[] { const words = text .toLowerCase() .split(/[^\p{L}\p{N}]+/u) .filter(Boolean); return Array.from(new Set(words)).slice(0, 20); } ``` Written at insert time, queried with `array-contains`: ```ts where('searchTokens', 'array-contains', term); ``` **This matches whole words only.** "migra" finds nothing where a Postgres `ilike '%migra%'` would find "migration". The empty state in the search screen says so, because a silent zero-result is worse than an honest one. The cap at 20 is not arbitrary — `array-contains` indexes every entry, and `firestore.rules` rejects a longer array outright. When this stops being enough, put a real search service in front of the collection; the Firestore console lists official Algolia, Typesense and Elastic extensions that mirror writes for you. Do not fetch the collection and filter in JS — you pay per document read. ## Aggregation ```ts const snapshot = await getCountFromServer(collection(db, 'tasks')); snapshot.data().count; ``` Counted server-side, with only the number coming back, so it costs a handful of reads rather than one per document. Never fetch a collection just to call `.length` on it. ## Writes ```ts title="hooks/useTasks.ts" await addDoc(collection(db, 'tasks'), { text: trimmed, isComplete: false, createdAt: serverTimestamp(), searchTokens: tokenize(trimmed), ownerId: userId, }); ``` A partial `updateDoc` is still validated against the **merged** document, so `isValidTask(request.resource.data)` holds even when you send one field. The corollary: if you ever let users edit `text`, rewrite `searchTokens` in the same `updateDoc` or the search index goes quietly stale. ### Batches Firestore caps a batch at 500 operations, which is why `useDeleteAccount` pages: ```ts title="hooks/useDeleteAccount.ts" const batch = writeBatch(db); snapshot.docs.forEach((task) => batch.delete(task.ref)); await batch.commit(); ``` ### undefined is an error `ignoreUndefinedProperties` is left at its default (`false`) and the hooks write explicit `null`s. Silently dropping a field is a worse failure mode than a loud throw — you find out months later that half your documents are missing a key. ## Offline There is **no offline disk cache**. Firestore's `persistentLocalCache` is backed by IndexedDB, which React Native does not have, so the cache here is per-session memory only. This is the main thing you give up by using the `firebase` JS SDK rather than `@react-native-firebase`, and it is the most likely reason to migrate later. ## Local development ```bash npm run emulators # UI at http://localhost:4000 npm run emulators:seed ``` Set `EXPO_PUBLIC_FIREBASE_USE_EMULATOR=1` and the client wires itself up: ```ts title="lib/firebase.ts" const host = Constants.expoConfig?.hostUri?.split(':')[0] ?? 'localhost'; connectFirestoreEmulator(db, host, 8080); ``` Reading the LAN address from Expo rather than hardcoding `localhost` is what makes this work from a real device, where `localhost` is the phone itself. ## Learn more - [Security rules](/docs/firebase/rules) — the rules these queries have to satisfy - [Realtime](/docs/firebase/realtime) — `onSnapshot` in detail - [Firestore documentation](https://firebase.google.com/docs/firestore) <!-- ---------------------------------------------------------------------- --> # Realtime > How onSnapshot works, why there is no applyChange reducer, and the three habits to unlearn when arriving from a REST or Supabase codebase. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/firebase/realtime - Markdown: https://ui.ahmedbna.com/docs/firebase/realtime.md --- Every Firestore read can be a live subscription. `onSnapshot` delivers the current result set and then re-delivers it on every change — to the query, from any device. ```ts title="hooks/useTasks.ts" return onSnapshot( query( collection(db, 'tasks'), where('ownerId', '==', userId), orderBy('createdAt', 'desc'), limit(50) ), { includeMetadataChanges: true }, (snapshot) => { setTasks(tasksFromSnapshot(snapshot)); setConnected(!snapshot.metadata.fromCache); setLoading(false); }, (caught) => { setError(messageFor(caught)); setLoading(false); } ); ``` `onSnapshot` returns its own unsubscribe function, which is why the effect returns it directly. ## Three habits to unlearn ### 1. No initial fetch `onSnapshot` delivers the current result set itself — from cache first if it has one, then from the server. A `select` before subscribing is a slower duplicate of the first callback. The Supabase starter's `useTasks` does one `select` on mount and then subscribes; the Firebase one has no equivalent line. ### 2. No optimistic apply, and no rollback `addDoc`, `updateDoc` and `deleteDoc` mutate the local cache **synchronously**. The listener re-fires with `hasPendingWrites: true` before the network is touched, and if the server rejects the write the SDK reverts the local mutation and fires again. That is roughly forty lines of bookkeeping in the Supabase hook that simply do not exist here. Compare: ```ts // Supabase: apply, then roll back on failure setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, is_complete: next } : t)) ); const { error } = await supabase .from('tasks') .update({ is_complete: next }) .eq('id', task.id); if (error) setTasks((prev) => prev.map((t) => (t.id === task.id ? task : t))); // Firestore: the SDK does both await updateDoc(doc(db, 'tasks', task.id), { isComplete: !task.isComplete }); ``` What the SDK does **not** do is tell the user a write was rejected. The awaited promise is the only place a `permission-denied` surfaces, so every mutation is wrapped: ```ts try { await updateDoc(/* … */); } catch (caught) { setError(messageFor(caught)); } ``` Forget that `try/catch` and a rejected write looks like a UI that flickers and reverts for no reason. ### 3. No refetch on reconnect The stream resumes from a resume token and the server sends what was missed. The Supabase hook refetches when the channel status returns to `SUBSCRIBED`; there is nothing equivalent to do here. ## There is no applyChange reducer The Supabase starter has `lib/realtime.ts` — a pure `applyChange(tasks, payload)` that folds one `postgres_changes` event into a local array, deduplicating inserts the device already applied. **Firestore has no honest counterpart, and the starter does not ship one.** `onSnapshot` hands back the entire ordered result set on every change. `docChanges()` exists so you can _animate_ a diff, not so you can stay correct. Folding it into an array by hand would redo work the SDK just did, and reintroduce a dedup problem Firestore does not have — latency compensation puts your own write into the very first snapshot. The whole realtime layer is this: ```ts title="lib/documents.ts" export function tasksFromSnapshot( snapshot: QuerySnapshot<DocumentData> ): Task[] { return snapshot.docs.map(taskFromDoc); } ``` The query's `orderBy` already sorted them. Re-sorting here would be a second, disagreeing source of truth. > `lib/documents.ts` still exists and is still unit-tested, but it covers the > boundary that actually breaks: snapshot → plain object. The > pending-`serverTimestamp` case, `doc.id` versus `data().id`, and the tokenizer > round trip search depends on. See [Firestore](/docs/firebase/firestore). ## The connection indicator ```ts setConnected(!snapshot.metadata.fromCache); ``` Firestore has no channel status to read, so the honest equivalent is "am I being served from the server or from cache". `includeMetadataChanges: true` is load-bearing here. Without it the listener does not re-fire when _only_ metadata changed — so `fromCache` never flips and the indicator stays grey forever after the first response. ## Errors are terminal This is the sharpest difference from a Supabase channel. When the error callback fires, Firestore has **already torn the listener down** and will not retry: ```ts (caught) => { setError(messageFor(caught)); setConnected(false); setLoading(false); }; ``` A `permission-denied` or a missing-index failure fires once and never again. There is no reconnect loop to hook into, which is why the hook exposes a `retry()` that bumps an `attempt` dependency and re-runs the effect: ```ts const retry = useCallback(() => { setError(null); setLoading(true); setAttempt((n) => n + 1); }, []); ``` The home screen renders a card with that button when `error` is set. Without it, a transient failure leaves the list permanently dead. ## Listening to a single document The auth provider watches the profile document the same way: ```ts title="providers/auth-provider.tsx" return onSnapshot(doc(db, 'users', userId), async (snapshot) => { if (!snapshot.exists()) { await ensureProfile(auth.currentUser); return; } setProfile(profileFromDoc(snapshot)); }); ``` This is the direct analogue of the `profiles:{id}` postgres\_changes channel, and it earns its place for the same reason: the onboarding screen writes to this document from elsewhere, and the route guard has to see that without polling. Creating the document from the _listener_ rather than once after sign-up is what makes it self-healing — if the first write never landed, the next launch fixes it. ## Cost Every document a listener delivers is a billed read, including the initial set and every re-delivery of a changed document. Two habits that matter: - **`limit()` every query.** The task list caps at 50. - **Use `getDocs`, not `onSnapshot`, for point-in-time answers.** The search screen does — a listener per keystroke would leak subscriptions and bill for each one. ## Learn more - [Firestore](/docs/firebase/firestore) — queries, indexes and the data model - [Security rules](/docs/firebase/rules) — why an unscoped query fails outright - [Firestore realtime documentation](https://firebase.google.com/docs/firestore/query-data/listen) <!-- ---------------------------------------------------------------------- --> # Storage > Uploading to Cloud Storage from React Native — resumable uploads with progress, the uid path convention, and why download URLs are capabilities. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/firebase/storage - Markdown: https://ui.ahmedbna.com/docs/firebase/storage.md --- Cloud Storage holds files; `storage.rules` decides who may touch them. Both starters upload images from the media picker. ## Uploading ```ts title="hooks/useUpload.ts" const blob = await fetch(asset.uri).then((res) => res.blob()); const task = uploadBytesResumable(ref(storage, path), blob, { contentType }); task.on('state_changed', (snapshot) => { setProgress( snapshot.totalBytes ? snapshot.bytesTransferred / snapshot.totalBytes : 0 ); }); const downloadUrl = await getDownloadURL(task.snapshot.ref); ``` Three details that matter: - **`fetch(uri).blob()`, not base64.** Base64 inflates the payload by a third and has to be held in JS memory in one piece, which is what makes large images crash on Android. - **`uploadBytesResumable`, not `uploadBytes`.** It reports bytes transferred as they go, which `uploadBytes` cannot, and it resumes rather than restarting when a large upload is interrupted. This is the one place the Firebase starter has something the Supabase one does not — hence the `progress` component in the settings tab. - **`contentType` set explicitly.** Storage otherwise infers `application/octet-stream`, which makes the object download instead of render _and_ fails the `contentType.matches('image/…')` condition in the rules. The `totalBytes` guard is not paranoia — it is 0 for a beat on some platforms, and dividing by it yields `NaN` straight into the progress bar. ## The uid in the path ```ts title="hooks/useAvatarUpload.ts" const path = `avatars/${user.uid}/avatar.${extension}`; ``` That first path segment is the entire access check: ``` match /avatars/{userId}/{fileName} { allow create, update: if isOwner(userId) && /* … */; } ``` It is the direct equivalent of `(storage.foldername(name))[1] = auth.uid()::text` in a Supabase storage policy. Build the path from anything other than `user.uid` and the server rejects it. A fixed filename also means one avatar per user, replaced in place rather than accumulating. ## Download URLs are capabilities `getDownloadURL()` returns a URL carrying an access token. That token grants read access to the object **regardless of what `storage.rules` says afterwards**: - Tightening the rule does not invalidate a URL already in circulation. - To actually revoke one, rotate the object's download token in the Firebase console. Treat these as shared links, not as permission checks. If a file must stay private, keep it under a path only its owner can read (`files/{uid}/…` in the auth starter) and do not hand the URL out. > The Supabase starter appends `?v=${Date.now()}` because its public URL is > stable across uploads, so the CDN keeps serving the old image. Firebase mints > a fresh download token per upload, so `getDownloadURL` already returns a > different URL and `useAvatarUpload` has no equivalent line. ## Keeping photoURL in step ```ts title="hooks/useAvatarUpload.ts" if (auth.currentUser) { await updateProfile(auth.currentUser, { photoURL: downloadUrl }).catch( () => {} ); } ``` The Firestore `users/{uid}` document is what the app renders from, but several Firebase features read the Auth record's `photoURL`, so both are updated. The `.catch` is deliberate: the Firestore write is the source of truth, and a failure here is not worth failing the upload over. ## Rules The no-auth starter allows public read and validated create under `uploads/`, with **no update and no delete** — with no signed-in user there is no way to tell whose object is whose. The auth starter has two prefixes: | Path | Read | Write | | ----------------- | ---------- | ------------------------ | | `avatars/{uid}/…` | Anyone | Owner, images under 2 MB | | `files/{uid}/…` | Owner only | Owner, under 10 MB | Avatars are public because they appear next to names, and signing every one of those URLs is a lot of round trips for a picture of a face. ### The delete trap `write` covers create + update + delete, and on a delete `request.resource` is `null`. So a combined rule with a `request.resource.size` condition silently denies every delete. Both starters split the methods: ``` allow create, update: if isOwner(userId) && request.resource.size < 2 * 1024 * 1024; allow delete: if isOwner(userId); ``` `useDeleteAccount` depends on this, and there is a test asserting it. ## Deleting `listAll` rather than a known filename, because the extension depends on what was uploaded and a leftover `avatar.png` beside a newer `avatar.jpg` would survive a targeted delete: ```ts title="hooks/useDeleteAccount.ts" const listing = await listAll(ref(storage, `avatars/${uid}`)); await Promise.all(listing.items.map((item) => deleteObject(item))); ``` ## Local development ```bash npm run emulators ``` With `EXPO_PUBLIC_FIREBASE_USE_EMULATOR=1`, uploads go to the Storage emulator and are visible in the Emulator UI at `http://localhost:4000`. Nothing touches your real bucket, and nothing is billed. ## Learn more - [Security rules](/docs/firebase/rules) — the delete trap, in full - [Firestore](/docs/firebase/firestore) - [Cloud Storage documentation](https://firebase.google.com/docs/storage) <!-- ---------------------------------------------------------------------- --> # Security rules > How Firestore and Storage rules actually work — why a query is checked instead of its results, the delete trap in Storage, and how to test both against the emulator. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/firebase/rules - Markdown: https://ui.ahmedbna.com/docs/firebase/rules.md --- Security rules are the boundary. Your Firebase config ships inside the app bundle, so anyone with the app can call the API directly; the route guards in `app/_layout.tsx` are there so users are not looking at empty screens, not to keep anyone out. Both starters ship rules **and tests that execute them**. That combination is unusual and it is the point: rules are a separate language that fails open in ways your app code cannot reveal. ## The one thing to internalise **A read rule is evaluated against the query, not against the documents it would return.** Firestore refuses any query it cannot prove _in advance_ is limited to documents the rule allows. It does not run the query and filter the results. ```ts // rule: allow read: if isOwner(resource.data.ownerId); // permission-denied — Firestore cannot prove this is scoped query(collection(db, 'tasks'), orderBy('createdAt', 'desc')); // fine — the filter matches the rule query(collection(db, 'tasks'), where('ownerId', '==', uid)); ``` This is the exact inverse of Postgres row level security, where the `WHERE` clause is optional and the policy silently narrows the result set. Code ported from the Supabase starter will fail loudly here, which is better than the alternative but still surprising. > `overlays/supabase-auth/app/(tabs)/search/index.tsx` deliberately leaves the > user filter off and lets RLS handle it. The Firebase search screen carries > `where('ownerId', '==', uid)` and a comment saying the opposite, because > without it the query returns nothing at all. ## Firestore rules ### The auth starter ``` match /users/{userId} { allow get: if isOwner(userId); allow create: if isOwner(userId) && request.resource.data.onboarded == false && request.resource.data.keys().hasOnly([...]); allow update: if isOwner(userId) && request.resource.data.diff(resource.data).affectedKeys() .hasOnly(['displayName', 'photoURL', 'onboarded', 'updatedAt']); } ``` Three things doing real work: - **The document id is the uid**, so `isOwner(userId)` is checkable without reading the document first. A `userId` field inside the document would cost a read on every rule evaluation. - **No `list`.** Nothing in the app queries this collection, and allowing it would let any signed-in user enumerate every account. - **`diff().affectedKeys().hasOnly(...)`** is how you say "these fields and no others" — the equivalent of leaving a column out of an `UPDATE` policy. `email` is excluded deliberately: it mirrors the Firebase Auth record, and letting the client edit it here would put the two out of step permanently. For tasks, both sides of an update are checked: ``` allow update: if isOwner(resource.data.ownerId) && request.resource.data.ownerId == resource.data.ownerId ``` `resource` stops you editing someone else's task; `request.resource` stops you handing yours to someone else. Dropping either half is a real hole. ### The no-auth starter Every rule is open, and the file says so in a comment. One thing is still pinned down: ``` allow create: if isValidTask(request.resource.data) && request.resource.data.createdAt == request.time; ``` A client cannot forge a creation time — no backdating a document to win an ordering, even in a demo. > The 30-day "test mode" rules the console offers expire into > `permission-denied` on day 31. A demo that breaks on a timer teaches the wrong > lesson, so these are honestly open instead. ### No catch-all match Neither starter has `match /{document=**}`. Anything not explicitly named is denied — the Firestore equivalent of leaving RLS on with no policy, and the single most common way a Firebase project leaks. Adding one back "to fix a permission error" opens every collection you ever create. The rules tests assert its absence for that reason. ## Storage rules ### The delete trap In Storage rules, `write` means **create + update + delete**, and on a delete `request.resource` is `null`. So this: ``` allow write: if isOwner(userId) && request.resource.size < 2 * 1024 * 1024; ``` silently denies every delete — `null` has no `.size`. Both starters split the methods: ``` allow create, update: if isOwner(userId) && request.resource.size < 2 * 1024 * 1024 && request.resource.contentType.matches('image/(jpeg|png|webp)'); allow delete: if isOwner(userId); ``` `hooks/useDeleteAccount.ts` depends on that split working, and there is a test asserting it. ### The uid in the path ``` match /avatars/{userId}/{fileName} { ... } ``` That path segment is the whole access check, exactly as `(storage.foldername(name))[1] = auth.uid()::text` is in a Supabase policy. It is why `useAvatarUpload` builds the path from `user.uid` rather than anything the picker returned. ### Download URLs are capabilities `getDownloadURL()` returns a URL carrying an access token. **Tightening the rule later does not invalidate a URL already in the wild** — you have to rotate the object's download token in the console. Treat those URLs as shared links, not as permission checks. ## Testing the rules ```bash npm run rules:test ``` This starts the emulators, runs `rules-tests/` under a separate Node jest project, and shuts them down again. It needs a **JDK 21 or newer** — the emulators are Java processes, and firebase-tools 15 refuses to start on anything older. The tests use `@firebase/rules-unit-testing`: ```ts title="rules-tests/firestore.test.ts" it('rejects an UNSCOPED query even for a signed-in user', async () => { await assertFails( getDocs(query(collection(alice(), 'tasks'), orderBy('createdAt', 'desc'))) ); }); it('accepts the same query once it is scoped to the owner', async () => { await assertSucceeds( getDocs(query(collection(alice(), 'tasks'), where('ownerId', '==', ALICE))) ); }); ``` `testEnv.withSecurityRulesDisabled()` seeds fixtures that the rules would otherwise reject — the admin escape hatch, used only for setup. > Firestore applies writes to its local cache before the server sees them, so a > write your rules reject still appears in the UI for a moment. Only the awaited > promise — and these tests — tell you what the server actually did. ## Deploying ```bash npm run deploy:rules # rules only npm run deploy:indexes # indexes only npm run deploy # both ``` Until you do this, Firestore uses whatever the console last had, which for a new project is usually locked down. Every screen showing an error toast on a fresh scaffold almost always means the rules were never deployed. ## Learn more - [Firestore](/docs/firebase/firestore) — the queries these rules have to allow - [Storage](/docs/firebase/storage) - [Firestore rules reference](https://firebase.google.com/docs/firestore/security/get-started) - [Storage rules reference](https://firebase.google.com/docs/storage/security) <!-- ---------------------------------------------------------------------- --> # Google sign-in > Wiring Google sign-in with expo-auth-session and signInWithCredential — the three client IDs, why a development build is required, and what fails when one is missing. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/firebase/google - Markdown: https://ui.ahmedbna.com/docs/firebase/google.md --- Google sign-in works by getting an ID token from Google, wrapping it in a Firebase credential, and handing that to `signInWithCredential`. ```ts title="components/auth/oauth-buttons.tsx" const result = await promptAsync(); if (result?.type !== 'success') return; // cancelled const idToken = result.params?.id_token; await signInWithCredential(auth, GoogleAuthProvider.credential(idToken)); ``` > Not Expo Go. `expo-auth-session`'s hosted proxy (`auth.expo.io`) was removed > in SDK 48, so under Expo Go the redirect is `exp://…`, which no Google OAuth > client type accepts. And there is no browser escape hatch: `signInWithPopup` > and `signInWithRedirect` throw > `auth/operation-not-supported-in-this-environment` on React Native. ```bash npx expo run:ios # or run:android, or an EAS development build ``` ## Enable the provider Firebase console → Authentication → Sign-in method → **Google** → Enable. That automatically creates a **Web application** OAuth client in the Google Cloud project your Firebase project owns. You will need two more. ## The three client IDs All from Google Cloud console → APIs & Services → **Credentials**, in the same project. | Variable | OAuth client type | Tied to | | -------------------------------------- | ----------------- | ------------------------------------------ | | `EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID` | Web application | Nothing — created by enabling the provider | | `EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID` | iOS | `ios.bundleIdentifier` | | `EXPO_PUBLIC_GOOGLE_ANDROID_CLIENT_ID` | Android | `android.package` + signing SHA-1 | ```bash title=".env.local" EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID=000000000000-xxxx.apps.googleusercontent.com EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID=000000000000-yyyy.apps.googleusercontent.com EXPO_PUBLIC_GOOGLE_ANDROID_CLIENT_ID=000000000000-zzzz.apps.googleusercontent.com ``` These are client **IDs**, not secrets — they are public by design, which is why `EXPO_PUBLIC_` is correct here. A client _secret_ would not be. > It is the one Firebase lists under the Google provider's "Web SDK > configuration", and the ID token's `aud` claim must match it or > `signInWithCredential` rejects the credential. Setting only the iOS client ID > produces a token Firebase refuses, with an error that does not point at the > cause. ## Set the identifiers first The iOS and Android OAuth clients cannot be created without them: ```json title="app.json" { "ios": { "bundleIdentifier": "com.yourcompany.myapp" }, "android": { "package": "com.yourcompany.myapp" } } ``` They are not in the scaffold already because a placeholder would break your first EAS build. For the Android client you also need the signing certificate's SHA-1. For a development build: ```bash eas credentials ``` Pick Android → the build profile → **Keystore: Manage everything** to see the SHA-1. A build signed with a different key — the debug keystore versus the EAS one — needs its own client, which is why Google sign-in often works locally and fails in a preview build. ## useIdTokenAuthRequest, not useAuthRequest ```ts const [request, , promptAsync] = Google.useIdTokenAuthRequest({ webClientId, iosClientId, androidClientId, }); ``` Firebase wants an **ID token**. Asking Google for an access token instead — the `useAuthRequest` default — is the single most common reason `signInWithCredential` rejects a Google credential. The `response` slot is deliberately unused: `promptAsync()` resolves with the same result, so the whole flow fits in the press handler rather than being split across an effect that watches it. That also avoids calling `setState` synchronously inside an effect, which the React compiler's lint rules reject. ## When nothing is configured `OAuthButtons` builds its list from what is actually available and renders `null` when the list is empty — including the "or" separator, which lives inside the component so `sign-in.tsx` does not have to know: ```ts const platformClientId = Platform.OS === 'ios' ? iosClientId : Platform.OS === 'android' ? androidClientId : webClientId; const googleConfigured = Boolean(webClientId && platformClientId); ``` A missing client ID is never a runtime error and never a button that fails when pressed. The app runs fine with email and password alone. ## Troubleshooting | Symptom | Cause | | ------------------------------- | -------------------------------------------------------------------- | | Browser opens, redirect fails | Running in Expo Go. Use a development build. | | `auth/invalid-credential` | The ID token's `aud` does not match the web client ID. | | No `id_token` in the result | Using `useAuthRequest` instead of `useIdTokenAuthRequest`. | | Works on iOS, fails on Android | Missing Android client, or the SHA-1 does not match the signing key. | | Works locally, fails in preview | Different signing key — add its SHA-1 as another Android client. | | `auth/unauthorized-domain` | Add the domain under Authentication → Settings → Authorized domains. | | Button does not appear | A client ID is unset. That is the intended behaviour. | ## A native sign-in sheet `@react-native-google-signin/google-signin` gives the system account picker rather than a browser, and returns an ID token you feed to the same `GoogleAuthProvider.credential(idToken)` call — so only the token-acquisition half changes. It needs its own config plugin and a development build, which the starter already assumes for this flow. ## Learn more - [Authentication](/docs/firebase/auth) · [Apple](/docs/firebase/apple) - [Firebase Google sign-in](https://firebase.google.com/docs/auth/web/google-signin) - [expo-auth-session](https://docs.expo.dev/versions/latest/sdk/auth-session/) <!-- ---------------------------------------------------------------------- --> # Apple sign-in > Sign in with Apple through expo-apple-authentication and signInWithCredential — the nonce dance, the one-shot name, and what App Store review requires. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/firebase/apple - Markdown: https://ui.ahmedbna.com/docs/firebase/apple.md --- Apple sign-in follows the same shape as Google: get an identity token from the platform, wrap it in a Firebase credential, hand it to `signInWithCredential`. ```ts title="components/auth/oauth-buttons.tsx" const rawNonce = Crypto.randomUUID(); const hashedNonce = await Crypto.digestStringAsync( Crypto.CryptoDigestAlgorithm.SHA256, rawNonce ); const appleCredential = await AppleAuthentication.signInAsync({ requestedScopes: [FULL_NAME, EMAIL], nonce: hashedNonce, }); const credential = new OAuthProvider('apple.com').credential({ idToken: appleCredential.identityToken, rawNonce, }); await signInWithCredential(auth, credential); ``` ## The nonce dance This is the part that goes wrong, and the error tells you nothing. Apple embeds a **hash** of the nonce in the identity token. Firebase compares that hash against the **raw** value you hand it. So: - Send the SHA-256 to Apple, as `nonce`. - Keep the plaintext for Firebase, as `rawNonce`. Swap them and you get `auth/invalid-credential` with no indication why. The comment in `oauth-buttons.tsx` says exactly this for the same reason. The nonce is not ceremony: it binds the token to this specific sign-in attempt, so a token intercepted from another session is useless. ## The name arrives once ```ts const given = appleCredential.fullName?.givenName; const family = appleCredential.fullName?.familyName; if ((given || family) && !result.user.displayName) { await updateProfile(result.user, { displayName: [given, family].filter(Boolean).join(' '), }); } ``` Apple sends `fullName` and `email` on the **very first authorization** for a given Apple ID and never again. Every subsequent sign-in returns `null` for both. If you do not capture the name there, it is gone. To test the first-time path again: Settings → your Apple ID → Sign in with Apple → your app → **Stop using Apple ID**. > A user who chooses "Hide My Email" gets a `something@privaterelay.appleid.com` > address. It is real and deliverable, but it is not the address they use > elsewhere — so do not treat email as a stable identity key across providers. > Firebase's account-linking settings decide whether that becomes a separate > account. ## Setup ### In the app Already done by the scaffold: ```json title="app.json" { "ios": { "usesAppleSignIn": true }, "plugins": ["expo-apple-authentication"] } ``` `usesAppleSignIn` writes the `com.apple.developer.applesignin` entitlement, which is what makes the native sheet appear. ### In the Apple Developer portal 1. Enable **Sign In with Apple** on your App ID. 2. Create a **Services ID** for the web/OAuth flow. 3. Create a **Key** with Sign In with Apple enabled, and download the `.p8`. You need a paid Apple Developer account for all three. ### In the Firebase console Authentication → Sign-in method → **Apple** → Enable, then fill in the Services ID, Apple Team ID, Key ID and the contents of the `.p8`. > `expo-apple-authentication` does function in Expo Go, but the token is issued > to Expo Go's bundle identifier — which your Firebase Apple provider does not > trust. Expo's own docs note the identifiers "will likely be different than in > standalone apps". Use a development build. ## Availability ```ts AppleAuthentication.isAvailableAsync().then(setAppleAvailable); ``` False on Android, on web, and on iOS below 13. The button only renders when this resolves true, so nothing to hide by hand. Cancellation arrives as an exception with `code === 'ERR_REQUEST_CANCELED'`, which the handler swallows rather than showing a toast — tapping Cancel is not an error. ## App Store review **If your app offers any third-party sign-in — Google included — Apple requires Sign in with Apple as an option on iOS.** App Store Review Guideline 4.8. Shipping Google-only is a common rejection. Email and password alone does not trigger the requirement. ## Troubleshooting | Symptom | Cause | | ---------------------------------------- | ------------------------------------------------------------------- | | `auth/invalid-credential` | Nonce swapped — `rawNonce` must be the plaintext. | | `auth/invalid-credential`, nonce correct | Services ID, Team ID or key mismatch in the console. | | Name is null on second sign-in | Expected. Apple sends it once. Revoke to re-test. | | Button missing on a simulator | `isAvailableAsync` is false. Sign into an Apple ID first. | | Works in dev build, fails in TestFlight | The App ID capability or the key was not configured for that build. | ## Learn more - [Authentication](/docs/firebase/auth) · [Google](/docs/firebase/google) - [Firebase Apple sign-in](https://firebase.google.com/docs/auth/web/apple) - [expo-apple-authentication](https://docs.expo.dev/versions/latest/sdk/apple-authentication/) <!-- ---------------------------------------------------------------------- --> # Email and links > Password reset, address verification and email-link sign-in — what works out of the box, and what the Dynamic Links shutdown changed. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/firebase/email - Markdown: https://ui.ahmedbna.com/docs/firebase/email.md --- Firebase sends three kinds of action email: password reset, address verification, and sign-in links. All three arrive as a URL carrying `mode` and `oobCode` parameters. ## What works out of the box | Flow | Default behaviour | Needs setup | | ------------------ | ----------------------------------------- | ----------- | | Password reset | Opens Firebase's hosted page in a browser | No | | Email verification | Opens Firebase's hosted page | No | | Email-link sign-in | Button is hidden | Yes | Password reset and verification work with zero configuration. The user changes their password on Google's page and comes back to the app to sign in — a perfectly good experience, and the one the starter ships with. Email-link sign-in cannot degrade that way: the whole point is landing back in the app, so `sign-in.tsx` hides the entry point until it is configured. ## Password reset ```ts title="app/(auth)/forgot-password.tsx" await sendPasswordResetEmail( auth, email.trim(), linkUrl ? { url: linkUrl, handleCodeInApp: true } : undefined ); ``` With enumeration protection on — the default since September 2023 — this resolves successfully even for an address with no account, so the form cannot be used to discover who has one. The screen says "if an account exists" for the same reason. ### In the app When `EXPO_PUBLIC_FIREBASE_LINK_URL` is set and the domain is claimed, the link opens `/reset-password` instead: ```ts title="app/(auth)/reset-password.tsx" const address = await verifyPasswordResetCode(auth, oobCode); // …show the form, tell the user which account this is for await confirmPasswordReset(auth, oobCode, password); router.replace('/sign-in'); ``` > This is the biggest difference from the Supabase starter, where the recovery > link is exchanged for a session first and `updateUser({password})` then works > without an old password. Firebase hands you a one-time `oobCode` instead: > verify it, spend it, and the user signs in normally afterwards. `verifyPasswordResetCode` runs before the form renders so an expired link shows "that link has expired" rather than a form that fails on submit. ## Email verification Firebase signs a new user in immediately, verified or not, so there is no blocked state to hold them in: ```ts title="app/(auth)/sign-up.tsx" await sendEmailVerification(user).catch(() => {}); ``` A card in Settings does the nagging and offers a resend. To make verification mandatory, gate the `(tabs)` guard on `user.emailVerified`. When the link comes back into the app: ```ts title="providers/auth-provider.tsx" await applyActionCode(auth, link.oobCode); await auth.currentUser?.reload(); // emailVerified is cached on the User setUser(auth.currentUser); ``` The `reload()` is not optional. The `User` object caches `emailVerified`, so without it the app keeps showing the unverified card after a successful verification. ## Email-link sign-in Firebase's name for what Supabase calls a magic link. ```ts title="app/(auth)/email-link.tsx" await sendSignInLinkToEmail(auth, address, { url: linkUrl, handleCodeInApp: true, }); await SecureStore.setItemAsync(PENDING_EMAIL_KEY, address); ``` The address is persisted because `signInWithEmailLink` needs it back, and the link may be opened from a mail app rather than this one. > Asking "which address was this?" when a link is opened is a phishing pattern. > So a link opened on a different device from the one that requested it is a > dead end by design — the provider reads the stored address, finds nothing, and > stops. Completion happens in the provider: ```ts if (isSignInWithEmailLink(auth, url)) { const email = await SecureStore.getItemAsync(PENDING_EMAIL_KEY); if (!email) return; await signInWithEmailLink(auth, email, url); await SecureStore.deleteItemAsync(PENDING_EMAIL_KEY); } ``` `isSignInWithEmailLink` is asked rather than parsing `mode=signIn` by hand — the SDK is the authority, and `lib/auth-link.ts` deliberately returns `null` for those so the two never double-handle a link. ## Getting links back into the app > The old behaviour — FDL wrapping the action link so a `page.link` domain could > bounce it into your app — is gone, and `ActionCodeSettings.dynamicLinkDomain` > is deprecated in favour of `linkDomain`. The supported approach now: 1. Firebase mints the link on your project's **Firebase Hosting** domain (`<project>.firebaseapp.com` by default, or a custom one via `linkDomain`). 2. Your app claims that domain through **Universal Links** on iOS and **App Links** on Android. ```json title="app.json" { "ios": { "associatedDomains": ["applinks:your-project-id.firebaseapp.com"] }, "android": { "intentFilters": [ { "action": "VIEW", "autoVerify": true, "data": [ { "scheme": "https", "host": "your-project-id.firebaseapp.com" } ], "category": ["BROWSABLE", "DEFAULT"] } ] } } ``` Then set the destination: ```bash title=".env.local" EXPO_PUBLIC_FIREBASE_LINK_URL=https://your-project-id.firebaseapp.com/finish-sign-in ``` Three constraints worth stating plainly: - **`url` must be https.** A custom scheme (`myapp://`) is rejected outright. - **The domain must be listed** under Authentication → Settings → Authorized domains. `<project>.firebaseapp.com` and `<project>.web.app` are there by default. - **This needs a build.** Associated domains are a native entitlement and `assetlinks.json` verification happens at install time, so it cannot work in Expo Go. The scaffold does **not** put those blocks in `app.json` — a placeholder host claims nothing, and the CLI writes `expo.scheme` as a string, so a scheme array would be clobbered on the next scaffold. `firebase.json` already ships a `hosting` block pointed at `public/`, so `firebase deploy --only hosting` works the moment you want a custom `linkDomain`. ## Parsing the link `lib/auth-link.ts` is pure and unit-tested, because deep-link bugs otherwise only surface on a real device with a real email: ```ts export function parseAuthLink(url: string): AuthLink | null; ``` It handles two shapes the naive version misses: a custom-scheme deep link, which `new URL()` parses inconsistently across platforms, and a link nested inside a `link=` parameter, which is what a Hosting domain produces — the outer URL has no `oobCode` of its own. ## Customising the emails Authentication → Templates in the console. You can change the sender name, subject, body and reply-to. To send from your own domain, set up a custom SMTP provider under Authentication → Templates → SMTP settings. Firebase's default sender is `noreply@<project>.firebaseapp.com`, which is fine for development and reaches spam folders more often than a verified domain in production. ## Learn more - [Authentication](/docs/firebase/auth) - [Firebase email link sign-in](https://firebase.google.com/docs/auth/web/email-link-auth) - [Dynamic Links migration](https://firebase.google.com/support/dynamic-links-faq) <!-- ---------------------------------------------------------------------- --> # Deployment > Shipping a Firebase app — deploying rules and indexes from CI with a service account, EAS builds, and the production checklist. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/firebase/deployment - Markdown: https://ui.ahmedbna.com/docs/firebase/deployment.md --- Two things ship separately: your **rules and indexes** go to Firebase, and your **app** goes to the stores through EAS. ## Deploying rules and indexes ```bash npm run deploy:rules # firestore.rules + storage.rules npm run deploy:indexes # firestore.indexes.json npm run deploy # both ``` Rules and indexes are code. Deploy them from CI on merge, not from a laptop — editing rules in the console means your repository and your production configuration disagree, and nothing tells you. > A client rolled out with a query whose index is missing fails for every user > at once. Deploy rules and indexes first, then submit the build. ### Indexes take time to build A new composite index on an existing collection is not instant — Firestore backfills it, which on a large collection can take minutes to hours. Queries that need it fail with `failed-precondition` until it is ready. Check progress under Firestore → Indexes. ## CI Both starters ship `.github/workflows/ci.yml` with four jobs: | Job | What it does | | -------- | --------------------------------------------------------------- | | `verify` | `tsc --noEmit`, lint, and the unit tests | | `rules` | Runs `firestore.rules` and `storage.rules` against the emulator | | `deploy` | Pushes rules and indexes on merge to `main` | | `build` | EAS build | The `rules` job is the one worth keeping. Everything else runs against mocks or the client SDK's local cache, both of which accept writes the server would reject. ```yaml - uses: actions/setup-java@v4 with: distribution: temurin java-version: 21 ``` The emulators are Java processes, and **firebase-tools 15 requires JDK 21 or newer** — it refuses to start on anything older. ### Authenticating the deploy ```yaml env: GOOGLE_APPLICATION_CREDENTIALS: ${{ github.workspace }}/service-account.json steps: - name: Write the service account key run: echo '${{ secrets.FIREBASE_SERVICE_ACCOUNT }}' > "$GOOGLE_APPLICATION_CREDENTIALS" - run: npx firebase-tools deploy --only firestore,storage --project "${{ secrets.FIREBASE_PROJECT_ID }}" - name: Remove the key if: always() run: rm -f "$GOOGLE_APPLICATION_CREDENTIALS" ``` > `firebase login:ci` tokens are deprecated in firebase-tools 13 and later, and > most tutorials still show them. Use a service account: IAM → Service accounts > → create one with the **Firebase Admin** role, download the JSON key, and > paste it whole into a repository secret. That key grants full admin access to your project and bypasses every rule in `firestore.rules`. The `if: always()` cleanup step matters — a private key written to a runner outlives the step that wrote it otherwise. It must never be committed, and `.gitignore` blocks `service-account*.json` for that reason. ## Environments The simplest split is two Firebase projects — `my-app-dev` and `my-app-prod` — with `.firebaserc` naming both: ```json title=".firebaserc" { "projects": { "default": "my-app-dev", "production": "my-app-prod" } } ``` ```bash npx firebase-tools deploy --only firestore,storage --project production ``` The app picks its project from `EXPO_PUBLIC_FIREBASE_*`, so the corresponding split lives on the EAS build profile. ## EAS builds ```bash eas build --platform all --profile preview ``` `EXPO_PUBLIC_` variables are **inlined into the bundle at build time**, so they must be set where the build runs — on the EAS build profile in `eas.json`, or as EAS environment variables. Setting them only in CI's shell environment produces a build with an undefined Firebase config, which fails at the first import with the error `lib/firebase.ts` throws. ### Before your first build ```json title="app.json" { "ios": { "bundleIdentifier": "com.yourcompany.myapp" }, "android": { "package": "com.yourcompany.myapp" } } ``` Not in the scaffold, because a placeholder breaks the build. The Google iOS and Android OAuth clients are tied to these values, so set them before creating those clients. ### Signing keys and Google sign-in An Android OAuth client is bound to a package name **and** a signing SHA-1. A development build signed with the debug keystore and a preview build signed with the EAS keystore are different fingerprints, so each needs its own client. This is why Google sign-in commonly works locally and fails in a preview build. `eas credentials` prints the SHA-1 for each profile. ## Production checklist - [ ] Rules deployed, and `npm run rules:test` passing in CI. - [ ] Indexes deployed and finished building. - [ ] Read `firestore.rules` line by line. On the no-auth starter it is **open** — anyone with the app can read and delete everything. - [ ] Confirm there is no `match /{document=**}`. - [ ] Decide whether email verification is mandatory. It is not by default. - [ ] Set a real password policy if you rely on the client-side rules. - [ ] Enable App Check if abuse is a concern — it attests that requests come from your app rather than a script with your config. - [ ] Set a budget alert. Firestore bills per document read, and a listener without `limit()` on a growing collection is the usual surprise. - [ ] If you offer Google sign-in on iOS, ship Apple sign-in too. App Store Review Guideline 4.8. - [ ] Confirm no service account JSON is in the repository. ## What this starter does not deploy **Cloud Functions.** They require the paid Blaze plan, and a starter should not force a billing account. The two places you would reach for one: - **Creating the profile document.** Handled client-side from the provider's snapshot listener, which self-heals. - **Cascading deletes on account deletion.** Handled best-effort on the device. Firebase's official "Delete User Data" extension is the robust version. Both are documented in [Authentication](/docs/firebase/auth). ## Learn more - [Security rules](/docs/firebase/rules) · [Troubleshooting](/docs/firebase/troubleshooting) - [EAS Build](https://docs.expo.dev/build/introduction/) - [Firebase CLI reference](https://firebase.google.com/docs/cli) <!-- ---------------------------------------------------------------------- --> # Troubleshooting > The failures this stack actually produces — permission-denied on a valid query, sessions that expire on relaunch, Metro resolution errors — and a Supabase to Firebase migration guide. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/firebase/troubleshooting - Markdown: https://ui.ahmedbna.com/docs/firebase/troubleshooting.md --- ## permission-denied on a query that looks fine The single most common one, and it is usually not a rules bug. **A read rule is evaluated against the query, not against the documents it would return.** Firestore refuses any query it cannot prove in advance is limited to documents the rule allows: ```ts // rule: allow read: if isOwner(resource.data.ownerId); query(collection(db, 'tasks'), orderBy('createdAt', 'desc')); // ❌ query(collection(db, 'tasks'), where('ownerId', '==', uid)); // ✅ ``` If you added a screen and it fails, check the query carries the same filter the rule keys on. See [Security rules](/docs/firebase/rules). The other cause: **the rules were never deployed.** A fresh project uses whatever the console last set. ```bash npm run deploy:rules ``` ## Everything is empty on a fresh scaffold Almost always one of two things: ```bash npx firebase-tools deploy --only firestore,storage # rules and indexes ``` or `.env.local` was never filled in. The client throws a readable error at import time when the config is missing, so check the Metro logs rather than the screen. ## "The query requires an index" Expected, and the error is doing you a favour — it carries a console URL that creates exactly the right index. `lib/errors.ts` passes that message through verbatim rather than replacing it with prose. Do not click through in production. Paste the generated definition into `firestore.indexes.json`, commit it, and: ```bash npm run deploy:indexes ``` A new index on an existing collection has to backfill, so queries keep failing until Firestore → Indexes shows it enabled. ## Users are signed out on every relaunch Persistence is not working. Either: - `lib/firebase.ts` threw its `getReactNativePersistence` error — check the logs. That is deliberate: the silent fallback is in-memory persistence, and failing loudly at startup beats a bug that reads as "sessions randomly expire". - Or you replaced `LargeSecureStore` with plain `expo-secure-store`. Firebase's user record exceeds its 2048-byte limit as soon as a Google identity with a long `photoURL` is attached — which is why it works with your test account and breaks for real users. ## createdAt.toMillis is not a function `serverTimestamp()` resolves to `null` in the local echo of a write — the snapshot delivered before the server acknowledges it. That is **every write**, not an edge case. Use the mapper in `lib/documents.ts`, or handle null in your own: ```ts createdAt: millisOf(doc.data().createdAt); // number | null ``` ## Metro cannot resolve firebase/auth If you hit this on Expo SDK 57, look for a `metro.config.js` you added. The `sourceExts.push('cjs')` and `unstable_enablePackageExports = true` advice in older Firebase + Expo threads describes defaults that `@expo/metro-config@57` and Metro 0.84 **already set**. Re-applying them by hand, particularly overwriting `unstable_conditionsByPlatform`, is now more likely to break resolution than fix it. Neither starter ships a `metro.config.js`. ## Firestore has already been started ``` FirebaseError: Firestore has already been started and its settings can no longer be changed ``` Something called `initializeFirestore` twice — usually a second module doing its own initialization, or a Fast Refresh re-run of an unguarded file. The starter derives everything from one `getApps()[0]` check: ```ts const existing = getApps()[0]; export const db = existing ? getFirestore(app) : initializeFirestore(app, settings); ``` Keep all initialization in `lib/firebase.ts`. ## Could not reach Cloud Firestore backend Usually a network path that blocks gRPC — a corporate proxy, some VPNs, or an awkward emulator setup. Swap the long-polling setting: ```ts initializeFirestore(app, { experimentalForceLongPolling: true }); ``` The two settings are **mutually exclusive** — remove `experimentalAutoDetectLongPolling` first or Firestore throws. ## Google sign-in does nothing | Symptom | Cause | | ------------------------------- | ----------------------------------------------------------------- | | Browser opens, redirect fails | Running in Expo Go. Needs a development build. | | `auth/invalid-credential` | ID token `aud` does not match `EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID`. | | No `id_token` in the result | Used `useAuthRequest` instead of `useIdTokenAuthRequest`. | | Works locally, fails in preview | Different signing key — add its SHA-1 as another Android client. | | Button does not appear | A client ID is unset. That is intended. | See [Google](/docs/firebase/google). ## Apple sign-in returns auth/invalid-credential The nonce is almost certainly swapped. Apple gets the **SHA-256 hash**; Firebase gets the **plaintext** as `rawNonce`. See [Apple](/docs/firebase/apple). ## The emulator will not start ``` firebase-tools no longer supports Java version before 21 ``` Install a JDK 21 or newer. The Firestore and Storage emulators are Java processes. ```bash java -version ``` ## Search finds nothing for a partial word Working as designed. Firestore has no `LIKE`, so search matches whole words against a `searchTokens` array — "migra" will not find "migration". If it finds nothing for a **whole** word, the tokens were not written: check that `add` calls `tokenize(text)`, and that any edit path rewrites `searchTokens` in the same `updateDoc`. ## Account deletion fails `auth/requires-recent-login`. Firebase refuses to delete an account on a token older than a few minutes. The confirm dialog asks a password user to type it; a federated user has to sign out and back in first. ## Data is left behind after deleting an account Expected, up to a point. Firestore has no `ON DELETE CASCADE`, so `useDeleteAccount` walks the data on the device — and a crash mid-way leaves orphans. For guarantees, use Firebase's "Delete User Data" extension or a Cloud Function on the `user.delete` trigger. Both need the Blaze plan, which is why neither ships here. ## Nothing is cached offline Firestore's `persistentLocalCache` is backed by IndexedDB, which React Native does not have. The cache is per-session memory only. This is the main limitation of the `firebase` JS SDK versus `@react-native-firebase`, and the most common reason to migrate later. --- # Moving from Supabase to Firebase The two starters build the same app, so most screens port with only import changes. These are the differences that matter. ## Authorization is inverted | | Supabase | Firebase | | ------------- | --------------------------- | ------------------------------- | | Where it runs | Postgres, per row | Google's servers, per **query** | | Unscoped read | Silently returns fewer rows | Fails with `permission-denied` | | The filter | Optional | **Mandatory** | This is the one that breaks ported code. A Supabase query relies on RLS to narrow the result; the Firebase equivalent must state the scope itself. ```ts // Supabase — RLS narrows this await supabase.from('tasks').select('*'); // Firebase — must be explicit or it fails outright query(collection(db, 'tasks'), where('ownerId', '==', uid)); ``` Note the Supabase starter's search screen deliberately _omits_ the user filter and says why; the Firebase one carries it and says the opposite. ## Schema and queries | Supabase | Firebase | | ------------------------------------ | -------------------------------------------------- | | SQL migrations | No schema — validate in `firestore.rules` | | `lib/database.types.ts` from codegen | Hand-written types in `lib/documents.ts` | | `is_complete`, `created_at` | `isComplete`, `createdAt` | | `ilike '%term%'` | `array-contains` over `searchTokens` — whole words | | Joins | Denormalise, or read twice | | `count()` | `getCountFromServer` | There is no `npm run db:types` equivalent because there is no schema to generate from. The CI job that checks generated types are current is replaced by one that executes the security rules. ## Realtime `onSnapshot` replaces the `postgres_changes` channel, and it does more: - No initial `select` — the listener delivers it. - No optimistic apply or rollback — the SDK handles both. - No refetch on reconnect — the stream resumes from a token. - **Errors are terminal.** A Supabase channel reconnects; a Firestore listener is torn down and needs an explicit retry. `lib/realtime.ts` has no counterpart and the starter ships none. See [Realtime](/docs/firebase/realtime). ## Auth | Supabase | Firebase | | ------------------------------------ | -------------------------------------------- | | `session` object | `User` with `getIdToken()` | | `AppState` refresh listener | Not needed | | Email OTP | **None** — SMS only | | GitHub via `signInWithOAuth` | **None** — needs a client secret | | Browser PKCE OAuth, works in Expo Go | Native ID token, needs a dev build | | `handle_new_user()` trigger | Client-side write from the snapshot listener | | `delete-account` edge function | Client-side `deleteUser`, best-effort | | Reset link returns to the app | Returns to a hosted page unless configured | `LargeSecureStore` carries over verbatim — Firebase's user record is over 2 KB for the same reason a Supabase session is. ## Functions Supabase edge functions have no free-tier equivalent: Cloud Functions require the Blaze plan. Before reaching for one, check whether the work can move to a security rule, a client-side call, or a Firebase extension — account deletion, for instance, needs no server at all here. ## Learn more - [Security rules](/docs/firebase/rules) — read this first - [Firestore](/docs/firebase/firestore) · [Authentication](/docs/firebase/auth) - [Report an issue](https://github.com/ahmedbna/ui/issues) <!-- ---------------------------------------------------------------------- --> # Hooks > Here you can find all the hooks available in the library. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/hooks - Markdown: https://ui.ahmedbna.com/docs/hooks.md --- - [useBottomTabOverflow](/docs/hooks/useBottomTabOverflow) — useBottomTabOverflow - [useColor](/docs/hooks/useColor) — useColor - [useColorScheme](/docs/hooks/useColorScheme) — useColorScheme - [useHaptics](/docs/hooks/useHaptics) — Semantic haptic feedback that routes each intent to the right native API per platform — performAndroidHapticsAsync on Android rather than the Vibrator-simulated impact APIs Expo discourages. - [useKeyboardHeight](/docs/hooks/useKeyboardHeight) — A React Native hook that tracks keyboard visibility, height, and animation duration with cross-platform support and screen rotation handling - [useModeToggle](/docs/hooks/useModeToggle) — useModeToggle <!-- ---------------------------------------------------------------------- --> # useBottomTabOverflow > A hook that returns the appropriate bottom tab bar height for iOS devices to handle safe area insets and overflow scenarios. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/hooks/useBottomTabOverflow - Markdown: https://ui.ahmedbna.com/docs/hooks/useBottomTabOverflow.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/useBottomTabOverflow.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/useBottomTabOverflow.json - Install: `npx bna-ui add useBottomTabOverflow` - npm dependencies: `expo-router` --- ## Installation ### CLI ```bash npx bna-ui add useBottomTabOverflow ``` ### Manual **1.** This hook reads from expo-router's tab context, so no additional dependencies are needed beyond expo-router itself. **2.** Copy and paste the following code into your project. ```ts // hooks/useBottomTabOverflow.ts import { BottomTabBarHeightContext } from 'expo-router/js-tabs'; import { useContext } from 'react'; import { Platform } from 'react-native'; // Reads the tab bar height off its context rather than calling // `useBottomTabBarHeight()`, for two reasons: the hook must run // unconditionally to satisfy the rules of hooks (the React Compiler is strict // about this), and the context returns `undefined` outside a tab navigator // where the hook would throw. Only iOS needs the inset — Android's tab bar is // opaque, so content is never underneath it. export function useBottomTabOverflow() { const tabBarHeight = useContext(BottomTabBarHeightContext); return Platform.OS === 'ios' ? (tabBarHeight ?? 0) : 0; } ``` **3.** Update the import paths to match your project setup. ## Usage ```tsx import { useBottomTabOverflow } from '@/hooks/useBottomTabOverflow'; ``` ```tsx export function MyScreen() { const tabOverflow = useBottomTabOverflow(); return ( <View style={{ paddingBottom: tabOverflow }}>{/* Your content */}</View> ); } ``` ## API Reference ### useBottomTabOverflow Returns the appropriate bottom tab bar height for handling overflow scenarios. #### Returns | Type | Description | | -------- | ---------------------------------------------------------------------------------------------------------------- | | `number` | The bottom tab bar height on iOS, or 0 on Android. Reads `BottomTabBarHeightContext` from `expo-router/js-tabs`. | ## Platform Behavior ### iOS - Returns the tab bar height from `expo-router`'s `BottomTabBarHeightContext`, falling back to `0` outside a tab navigator - Updates dynamically if the tab bar height changes ### Android - Returns 0 as Android handles bottom tabs differently - Android's bottom navigation typically doesn't overlap with content ## Use Cases This hook is particularly useful when you need to: - Prevent content from being hidden behind bottom tab bars on iOS - Add appropriate padding to scrollable content - Handle safe area insets in screens with bottom navigation - Position floating action buttons or other fixed elements above the tab bar - Ensure modal content doesn't overlap with navigation elements ## Best Practices ### Performance - The hook is lightweight (a single `useContext` read) and safe to use in multiple components - Consider memoizing dependent calculations to avoid unnecessary re-renders ### Usage Patterns - Use this hook in screens that have scrollable content or fixed positioned elements - Combine with `useSafeAreaInsets` for complete safe area handling - Consider using it in modal screens that might overlap with tab bars ### Example: Complete Safe Area Handling ```tsx import { useBottomTabOverflow } from '@/hooks/useBottomTabOverflow'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; export function CompleteScreen() { const tabOverflow = useBottomTabOverflow(); const insets = useSafeAreaInsets(); return ( <ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingTop: insets.top, paddingBottom: Math.max(insets.bottom, tabOverflow), paddingHorizontal: 16, }} > {/* Your content */} </ScrollView> ); } ``` ### Example: Fixed Position Element ```tsx import { useBottomTabOverflow } from '@/hooks/useBottomTabOverflow'; export function ScreenWithFAB() { const tabOverflow = useBottomTabOverflow(); return ( <View style={{ flex: 1 }}> {/* Main content */} <View style={{ flex: 1 }}>{/* Content */}</View> {/* Fixed position FAB */} <TouchableOpacity style={{ position: 'absolute', bottom: 16 + tabOverflow, right: 16, width: 56, height: 56, borderRadius: 28, backgroundColor: '#007AFF', justifyContent: 'center', alignItems: 'center', }} > <Text style={{ color: 'white', fontSize: 24 }}>+</Text> </TouchableOpacity> </View> ); } ``` ## Dependencies - `expo-router` - Provides `BottomTabBarHeightContext` via `expo-router/js-tabs` - `react-native` - Required for Platform detection ## Accessibility The hook helps maintain proper accessibility by: - Ensuring content is not hidden behind navigation elements - Maintaining proper touch targets for interactive elements - Supporting screen readers by keeping content visible and accessible <!-- ---------------------------------------------------------------------- --> # useColor > A hook that returns the appropriate color based on the current theme, with support for prop overrides and fallback to predefined color palettes. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/hooks/useColor - Markdown: https://ui.ahmedbna.com/docs/hooks/useColor.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/useColor.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/useColor.json - Install: `npx bna-ui add useColor` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors` --- ## Installation ### CLI ```bash npx bna-ui add useColor ``` ### Manual **1.** This hook requires the useColorScheme hook and Colors theme. Install dependencies first: ```bash npx bna-ui add useColorScheme ``` **2.** Copy and paste the following code into your project. ```ts // hooks/useColor.ts import { useColorScheme } from '@/hooks/useColorScheme'; import { Colors } from '@/theme/colors'; export function useColor( colorName: keyof typeof Colors.light & keyof typeof Colors.dark, props?: { light?: string; dark?: string } ) { const theme = useColorScheme() ?? 'light'; const colorFromProps = props?.[theme]; if (colorFromProps) { return colorFromProps; } else { return Colors[theme][colorName]; } } ``` **3.** Ensure you have a Colors theme file with light and dark variants. ```ts // theme/colors.ts const lightColors = { // Base colors background: '#FFFFFF', foreground: '#000000', // Card colors card: '#F2F2F7', cardForeground: '#000000', // Popover colors popover: '#F2F2F7', popoverForeground: '#000000', // Primary colors primary: '#18181b', primaryForeground: '#FFFFFF', // Secondary colors secondary: '#F2F2F7', secondaryForeground: '#18181b', // Muted colors muted: '#78788033', mutedForeground: '#71717a', // Accent colors accent: '#F2F2F7', accentForeground: '#18181b', // Destructive colors destructive: '#ef4444', destructiveForeground: '#FFFFFF', // Border and input border: '#C6C6C8', input: '#e4e4e7', ring: '#a1a1aa', // Text colors text: '#000000', textMuted: '#71717a', // Legacy support for existing components tint: '#18181b', icon: '#71717a', tabIconDefault: '#71717a', tabIconSelected: '#18181b', // Default buttons, links, Send button, selected tabs blue: '#007AFF', // Success states, FaceTime buttons, completed tasks green: '#34C759', // Delete buttons, error states, critical alerts red: '#FF3B30', // VoiceOver highlights, warning states orange: '#FF9500', // Notes app accent, Reminders highlights yellow: '#FFCC00', // Pink accent color for various UI elements pink: '#FF2D92', // Purple accent for creative apps and features purple: '#AF52DE', // Teal accent for communication features teal: '#5AC8FA', // Indigo accent for system features indigo: '#5856D6', // Semantic states success: '#22c55e', successForeground: '#ffffff', warning: '#f59e0b', warningForeground: '#ffffff', info: '#3b82f6', infoForeground: '#ffffff', error: '#ef4444', errorForeground: '#ffffff', }; const darkColors = { // Base colors background: '#000000', foreground: '#FFFFFF', // Card colors card: '#1C1C1E', cardForeground: '#FFFFFF', // Popover colors popover: '#18181b', popoverForeground: '#FFFFFF', // Primary colors primary: '#e4e4e7', primaryForeground: '#18181b', // Secondary colors secondary: '#1C1C1E', secondaryForeground: '#FFFFFF', // Muted colors muted: '#78788033', mutedForeground: '#a1a1aa', // Accent colors accent: '#1C1C1E', accentForeground: '#FFFFFF', // Destructive colors destructive: '#dc2626', destructiveForeground: '#FFFFFF', // Border and input - using alpha values for better blending border: '#38383A', input: 'rgba(255, 255, 255, 0.15)', ring: '#71717a', // Text colors text: '#FFFFFF', textMuted: '#a1a1aa', // Legacy support for existing components tint: '#FFFFFF', icon: '#a1a1aa', tabIconDefault: '#a1a1aa', tabIconSelected: '#FFFFFF', // Default buttons, links, Send button, selected tabs blue: '#0A84FF', // Success states, FaceTime buttons, completed tasks green: '#30D158', // Delete buttons, error states, critical alerts red: '#FF453A', // VoiceOver highlights, warning states orange: '#FF9F0A', // Notes app accent, Reminders highlights yellow: '#FFD60A', // Pink accent color for various UI elements pink: '#FF375F', // Purple accent for creative apps and features purple: '#BF5AF2', // Teal accent for communication features teal: '#64D2FF', // Indigo accent for system features indigo: '#5E5CE6', // Semantic states success: '#16a34a', successForeground: '#ffffff', warning: '#d97706', warningForeground: '#ffffff', info: '#2563eb', infoForeground: '#ffffff', error: '#dc2626', errorForeground: '#ffffff', }; export const Colors = { light: lightColors, dark: darkColors, }; // Export individual color schemes for easier access export { darkColors, lightColors }; // Utility type for color keys export type ColorKeys = keyof typeof lightColors; // Helper function to get color with opacity (useful for React Native) export const withOpacity = (color: string, opacity: number) => { // Handle rgba colors if (color.startsWith('rgba')) { return color; } // Handle hex colors if (color.startsWith('#')) { const hex = color.replace('#', ''); const r = parseInt(hex.substr(0, 2), 16); const g = parseInt(hex.substr(2, 2), 16); const b = parseInt(hex.substr(4, 2), 16); return `rgba(${r}, ${g}, ${b}, ${opacity})`; } return color; }; ``` **4.** Update the import paths to match your project setup. ## Usage ```tsx import { useColor } from '@/hooks/useColor'; ``` ```tsx export function ThemedText({ style, ...props }) { const color = useColor('text', { light: '#000', dark: '#fff' }); return <Text style={[{ color }, style]} {...props} />; } ``` ## API Reference ### useColor Returns the appropriate color based on the current theme, with support for prop overrides. #### Parameters | Parameter | Type | Description | | ----------- | ----------------------------------- | -------------------------------------------------- | | `props` | `{ light?: string; dark?: string }` | Optional color overrides for light and dark themes | | `colorName` | `keyof Colors.light & Colors.dark` | The color key from your Colors theme object | #### Returns | Type | Description | | -------- | ----------------------------------------------------------------------------------------------- | | `string` | The resolved color value - either from props override or from the Colors theme for current mode | ## Color Resolution Priority The hook resolves colors in the following order: 1. **Prop Override**: If a color is provided in props for the current theme 2. **Theme Fallback**: The color from the Colors theme object for the current theme 3. **Light Default**: Falls back to light theme if current theme is null ```tsx // Example resolution flow const color = useColor( { light: '#custom-light', dark: '#custom-dark' }, 'primary' ); // If current theme is 'dark': // 1. Returns '#custom-dark' (prop override) // 2. If no dark prop, returns Colors.dark.primary // 3. If no Colors.dark, falls back to Colors.light.primary ``` ## Theme Structure Your Colors theme should follow this structure: ```tsx // theme/colors.ts export const Colors = { light: { text: '#000000', background: '#ffffff', primary: '#007AFF', secondary: '#8E8E93', border: '#E5E5E7', // ... other colors }, dark: { text: '#ffffff', background: '#000000', primary: '#0A84FF', secondary: '#636366', border: '#38383A', // ... other colors }, }; ``` ## Use Cases This hook is perfect for: - Creating theme-aware components that respect system preferences - Building consistent color systems across your application - Allowing component-level color customization while maintaining theme consistency - Creating reusable UI components with proper dark mode support - Implementing accessible color schemes with proper contrast ratios ## Best Practices ### Component Design Patterns Create themed components that accept color overrides: ```tsx interface ThemedButtonProps { title: string; onPress: () => void; colors?: { light?: string; dark?: string }; variant?: 'primary' | 'secondary'; } export function ThemedButton({ title, onPress, colors, variant = 'primary', }: ThemedButtonProps) { const backgroundColor = useColor( colors || {}, variant === 'primary' ? 'primary' : 'secondary' ); const textColor = useColor('background'); return ( <TouchableOpacity style={{ backgroundColor, padding: 16, borderRadius: 8, alignItems: 'center', }} onPress={onPress} > <Text style={{ color: textColor, fontWeight: 'bold' }}>{title}</Text> </TouchableOpacity> ); } ``` ### Consistent Color Naming Use consistent color names across your theme: ```tsx // Good: Semantic color names const Colors = { light: { text: '#000000', textSecondary: '#666666', background: '#ffffff', backgroundSecondary: '#f8f8f8', primary: '#007AFF', primaryLight: '#5AC8FA', danger: '#FF3B30', success: '#34C759', warning: '#FF9500', }, // ... dark theme }; ``` ### Performance Optimization Memoize complex color calculations: ```tsx import { useMemo } from 'react'; export function useThemedStyles() { const backgroundColor = useColor('background'); const textColor = useColor('text'); const borderColor = useColor('border'); return useMemo( () => ({ container: { backgroundColor, borderColor, borderWidth: 1, borderRadius: 8, padding: 16, }, text: { color: textColor, fontSize: 16, }, }), [backgroundColor, textColor, borderColor] ); } ``` ### Type Safety Ensure type safety with proper TypeScript definitions: ```tsx // theme/colors.ts export const Colors = { light: { text: '#000000', background: '#ffffff', primary: '#007AFF', // ... other colors }, dark: { text: '#ffffff', background: '#000000', primary: '#0A84FF', // ... other colors }, } as const; // This ensures colorName parameter is properly typed export type ColorName = keyof typeof Colors.light & keyof typeof Colors.dark; ``` ## Advanced Usage ### Contextual Color Variations Create variations of colors based on context: ```tsx export function useContextualColor( baseColor: keyof typeof Colors.light & keyof typeof Colors.dark, variant: 'default' | 'muted' | 'emphasis' = 'default' ) { const theme = useColorScheme() ?? 'light'; const baseColorValue = Colors[theme][baseColor]; // Apply contextual modifications switch (variant) { case 'muted': return theme === 'dark' ? `${baseColorValue}80` // Add opacity : `${baseColorValue}60`; case 'emphasis': return theme === 'dark' ? lighten(baseColorValue, 0.2) : darken(baseColorValue, 0.1); default: return baseColorValue; } } ``` ### Animated Color Transitions Combine with animations for smooth theme transitions: ```tsx import { useEffect, useRef } from 'react'; import { Animated } from 'react-native'; export function useAnimatedThemeColor( props: { light?: string; dark?: string }, colorName: keyof typeof Colors.light & keyof typeof Colors.dark ) { const color = useColor(props, colorName); const animatedColor = useRef(new Animated.Value(0)).current; useEffect(() => { Animated.timing(animatedColor, { toValue: 1, duration: 300, useNativeDriver: false, }).start(); }, [color]); return color; // In practice, you'd interpolate the animated value } ``` ## Dependencies - `@/hooks/useColorScheme` - Required for theme detection - `@/theme/colors` - Required for color palette definitions ## Accessibility The hook supports accessibility by: - Respecting system-level dark mode preferences - Enabling proper color contrast ratios through theme definitions - Supporting high contrast modes when defined in your color palette - Maintaining consistent color relationships across themes ## Related Hooks - [`useColorScheme`](/docs/hooks/useColorScheme) - Base hook for theme detection ## References Learn more about implementing color schemes in Expo: - [Expo Color Schemes Guide](https://docs.expo.dev/guides/color-schemes/) - [React Native Appearance API](https://reactnative.dev/docs/appearance) - [iOS Human Interface Guidelines - Dark Mode](https://developer.apple.com/design/human-interface-guidelines/dark-mode) - [Material Design - Dark Theme](https://material.io/design/color/dark-theme.html) <!-- ---------------------------------------------------------------------- --> # useColorScheme > A cross-platform hook that provides access to the user's preferred color scheme with hydration-safe web support. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/hooks/useColorScheme - Markdown: https://ui.ahmedbna.com/docs/hooks/useColorScheme.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/useColorScheme.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/useColorScheme.json - Install: `npx bna-ui add useColorScheme` - Registry dependencies: `mode-provider` --- ## Installation ### CLI ```bash npx bna-ui add useColorScheme ``` ### Manual **1.** This hook reads the theme mode from ModeProvider. Install it first: ```bash npx bna-ui add mode-provider ``` **2.** Copy and paste the following code into your project. ```ts // hooks/useColorScheme.ts import { useColorScheme as useRNColorScheme } from 'react-native'; import { useModeContext } from '@/providers/mode-provider'; /** * The one place the app's colour scheme is decided. * * A mounted `ModeProvider` wins, so an in-app light/dark toggle works on every * platform — including web, where react-native-web has no * `Appearance.setColorScheme` for the toggle to write through. With no provider * this is just the OS scheme, so installing `useColor` alone still behaves as * it always has. * * React Native 0.86 widened `ColorSchemeName` to `'light' | 'dark' | * 'unspecified'`. The theme is binary — `Colors` only has `light` and `dark` * keys — so collapse the third value here, once, and let every consumer keep * indexing with a two-value union. */ export function useColorScheme(): 'light' | 'dark' { const system = useRNColorScheme() === 'dark' ? 'dark' : 'light'; return useModeContext()?.scheme ?? system; } ``` **3.** For web support, also copy the web-specific implementation. ```ts // hooks/useColorScheme.web.ts import { useEffect, useState } from 'react'; import { useColorScheme as useRNColorScheme } from 'react-native'; import { useModeContext } from '@/providers/mode-provider'; /** * To support static rendering, this value needs to be re-calculated on the client side for web. * * Mirrors the native variant: a mounted `ModeProvider` wins, falling back to the * OS scheme. The provider is what makes the toggle work here at all — * react-native-web's `Appearance` is read-only, exposing `getColorScheme` and * `addChangeListener` but no setter, so nothing can push an override into the * value `useRNColorScheme()` reports. * * React Native 0.86's `ColorSchemeName` includes `'unspecified'`, which the * binary theme has no slot for, so it collapses here. */ export function useColorScheme(): 'light' | 'dark' { const [hasHydrated, setHasHydrated] = useState(false); useEffect(() => { setHasHydrated(true); }, []); const system = useRNColorScheme() === 'dark' ? 'dark' : 'light'; const scheme = useModeContext()?.scheme ?? system; if (hasHydrated) { return scheme; } return 'light'; } ``` **4.** Update the import paths to match your project setup. ## Usage ```tsx import { useColorScheme } from '@/hooks/useColorScheme'; ``` ```tsx export function ThemedComponent() { const colorScheme = useColorScheme(); return ( <View style={{ backgroundColor: colorScheme === 'dark' ? '#000' : '#fff', }} > {/* Your themed content */} </View> ); } ``` ## API Reference ### useColorScheme Returns the color scheme the app should render as, with platform-specific optimizations. A mounted [`ModeProvider`](/docs/providers/mode-provider) wins — that is how an in-app light/dark toggle repaints your components on every platform. With no provider mounted this is simply the OS scheme, so the hook works standalone. #### Returns | Type | Description | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `'light' \| 'dark'` | The current color scheme. Never `null` — React Native's own `useColorScheme` can return `'unspecified'` (the OS has no preference set), which this hook collapses to `'light'`. | ## Platform Behavior ### Native (iOS/Android) - Uses React Native's built-in `useColorScheme` hook - Automatically updates when the system color scheme changes - Returns the actual system preference immediately - Listens for system-level theme changes ### Web - Implements hydration-safe color scheme detection - Returns `'light'` during server-side rendering to prevent hydration mismatches - Updates to the actual system preference after client-side hydration - Listens for system color scheme changes via media queries ## Implementation Details The hook uses platform-specific files to handle different environments: - `useColorScheme.ts` - Default implementation for React Native - `useColorScheme.web.ts` - Web-specific implementation with hydration safety ### Web Hydration Safety The web implementation includes a hydration check to prevent mismatches between server and client rendering: ```tsx const [hasHydrated, setHasHydrated] = useState(false); useEffect(() => { setHasHydrated(true); }, []); if (hasHydrated) { return colorScheme; } return 'light'; // Safe default during SSR ``` ## Use Cases This hook is essential for: - Implementing dark mode support in your applications - Creating theme-aware components - Adapting UI colors based on system preferences - Ensuring consistent theming across platforms - Building accessible applications with proper contrast ratios ## Best Practices ### Theme Provider Pattern Create a centralized theme provider for consistent theming: ```tsx import { useColorScheme } from '@/hooks/useColorScheme'; import { createContext, useContext } from 'react'; const ThemeContext = createContext<'light' | 'dark'>('light'); export function ThemeProvider({ children }: { children: React.ReactNode }) { const colorScheme = useColorScheme(); return ( <ThemeContext.Provider value={colorScheme ?? 'light'}> {children} </ThemeContext.Provider> ); } export function useTheme() { return useContext(ThemeContext); } ``` ### Color Palette Management Define comprehensive color palettes for each theme: ```tsx const colors = { light: { background: '#ffffff', text: '#000000', primary: '#007AFF', secondary: '#8E8E93', border: '#E5E5E7', }, dark: { background: '#000000', text: '#ffffff', primary: '#0A84FF', secondary: '#636366', border: '#38383A', }, }; export function useThemeColors() { const colorScheme = useColorScheme(); return colors[colorScheme ?? 'light']; } ``` ### Performance Optimization Memoize theme-dependent calculations to avoid unnecessary re-renders: ```tsx import { useMemo } from 'react'; export function useThemedStyles() { const colorScheme = useColorScheme(); return useMemo( () => ({ container: { backgroundColor: colorScheme === 'dark' ? '#000' : '#fff', flex: 1, }, text: { color: colorScheme === 'dark' ? '#fff' : '#000', }, }), [colorScheme] ); } ``` ### Testing Both Modes Always test your application in both light and dark modes: ```tsx // Test component export function ThemeTestComponent() { const colorScheme = useColorScheme(); return ( <View style={{ padding: 20 }}> <Text>Current theme: {colorScheme}</Text> <Text>Test your components in both modes!</Text> </View> ); } ``` ## Advanced Usage ### Custom Theme Hook Create a more sophisticated theme hook with additional features: ```tsx import { useColorScheme } from '@/hooks/useColorScheme'; import { useMemo } from 'react'; interface Theme { colors: { primary: string; secondary: string; background: string; text: string; border: string; }; spacing: { xs: number; sm: number; md: number; lg: number; xl: number; }; borderRadius: { sm: number; md: number; lg: number; }; } export function useTheme(): Theme { const colorScheme = useColorScheme(); return useMemo(() => { const isDark = colorScheme === 'dark'; return { colors: { primary: isDark ? '#0A84FF' : '#007AFF', secondary: isDark ? '#636366' : '#8E8E93', background: isDark ? '#000000' : '#FFFFFF', text: isDark ? '#FFFFFF' : '#000000', border: isDark ? '#38383A' : '#E5E5E7', }, spacing: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32, }, borderRadius: { sm: 4, md: 8, lg: 12, }, }; }, [colorScheme]); } ``` ## Dependencies - `react-native` - Required for the base `useColorScheme` hook - `react` - Required for the web implementation hooks (useState, useEffect) ## Accessibility The hook helps maintain proper accessibility by: - Respecting user's system-level accessibility preferences - Supporting high contrast modes automatically - Enabling proper color contrast ratios for different themes - Ensuring consistent theming across the application <!-- ---------------------------------------------------------------------- --> # useHaptics > Semantic haptic feedback that routes each intent to the right native API per platform, using performAndroidHapticsAsync on Android instead of the Vibrator-simulated impact APIs. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/hooks/useHaptics - Markdown: https://ui.ahmedbna.com/docs/hooks/useHaptics.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/useHaptics.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/useHaptics.json - Install: `npx bna-ui add useHaptics` - npm dependencies: `expo-haptics` --- ## Installation ### CLI ```bash npx bna-ui add useHaptics ``` ### Manual **1.** Copy and paste the following code into your project. ````ts // hooks/useHaptics.ts import * as Haptics from 'expo-haptics'; import { useCallback } from 'react'; import { Platform } from 'react-native'; /** * What a haptic is *for*, rather than which API to call. * * iOS and web share one API surface — `impactAsync` / `notificationAsync` / * `selectionAsync`. Web is not a no-op: expo-haptics drives `navigator.vibrate` * there, falling back to an iOS-Safari switch-element trick. * * Android must not use those. They are simulated with the raw `Vibrator` * service, which Expo explicitly does not recommend: it is a coarse timed buzz * and nothing like what native Android controls feel like. * `performAndroidHapticsAsync` goes through `View.performHapticFeedback` * instead, which is exactly what those controls use. * * Branching on that is the whole reason this file exists, so that no component * has to know about it. */ export type HapticIntent = | 'selection' | 'tick' | 'toggle-on' | 'toggle-off' | 'impact-light' | 'impact-medium' | 'success' | 'warning' | 'error'; const ANDROID_HAPTICS: Record<HapticIntent, Haptics.AndroidHaptics> = { selection: Haptics.AndroidHaptics.Segment_Tick, // Clock_Tick rather than Segment_Frequent_Tick: the latter is documented as // possibly producing no vibration at all on devices that cannot make a // suitably soft one, which would silently drop repeated ticks. tick: Haptics.AndroidHaptics.Clock_Tick, 'toggle-on': Haptics.AndroidHaptics.Toggle_On, 'toggle-off': Haptics.AndroidHaptics.Toggle_Off, 'impact-light': Haptics.AndroidHaptics.Virtual_Key, 'impact-medium': Haptics.AndroidHaptics.Long_Press, success: Haptics.AndroidHaptics.Confirm, warning: Haptics.AndroidHaptics.Reject, error: Haptics.AndroidHaptics.Reject, }; function perform(intent: HapticIntent): Promise<void> { if (Platform.OS === 'android') { return Haptics.performAndroidHapticsAsync(ANDROID_HAPTICS[intent]); } switch (intent) { case 'selection': case 'tick': return Haptics.selectionAsync(); case 'impact-medium': return Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); case 'success': return Haptics.notificationAsync( Haptics.NotificationFeedbackType.Success ); case 'warning': return Haptics.notificationAsync( Haptics.NotificationFeedbackType.Warning ); case 'error': return Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error); default: // toggle-on, toggle-off, impact-light return Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); } } /** * Fire and forget. Never throws and never rejects. * * Haptics are decoration: a device with no taptic engine, a user who turned * touch feedback off, or a build where the native module is missing (which * makes expo-haptics reject with `UnavailabilityError`) must not be able to * take an `onPress` handler down with it. * * Call this on the JS thread. The expo module is bound to the JS runtime, so * invoking it from a Reanimated worklet running on the UI thread will throw — * reach for `runOnJS` at those call sites. */ export function triggerHaptic(intent: HapticIntent = 'impact-light'): void { try { perform(intent).catch(() => {}); } catch { // Unreachable today, since every expo-haptics entry point is async. Kept so // that a future version throwing synchronously cannot break a press either. } } /** * Returns a stable trigger that no-ops while `enabled` is false — the shape * every component's `haptic` prop plugs into: * * ```tsx * const feedback = useHaptics(haptic); * feedback('toggle-on'); * ``` * * Its identity only changes when `enabled` does, so it is safe to list in a * `useCallback` dependency array and will not defeat a `React.memo` boundary. */ export function useHaptics(enabled: boolean = true) { return useCallback( (intent: HapticIntent = 'impact-light') => { if (!enabled) return; triggerHaptic(intent); }, [enabled] ); } ```` **2.** Update the import paths to match your project setup. ## Usage ```tsx import { useHaptics, triggerHaptic } from '@/hooks/useHaptics'; ``` ```tsx export function SaveButton({ haptic = true, onSave }) { const feedback = useHaptics(haptic); return ( <Button haptic={false} onPress={() => { feedback('success'); onSave(); }} > Save </Button> ); } ``` You describe what the haptic is _for_ — a selection, a toggle, a success — and the hook picks the API. Every component in this library that has a `haptic` prop is built on it, so you rarely call it directly unless you are building your own control. ```tsx import { useHaptics } from '@/hooks/useHaptics'; export function Stepper({ value, onChange, haptic = true }) { const feedback = useHaptics(haptic); return ( <Button haptic={false} onPress={() => { feedback('tick'); onChange(value + 1); }} > + </Button> ); } ``` ## API Reference ### useHaptics ```tsx const feedback = useHaptics(enabled?: boolean); ``` Returns a stable trigger `(intent?: HapticIntent) => void` that does nothing while `enabled` is `false`. Its identity only changes when `enabled` does, so it is safe in a `useCallback` dependency array and will not defeat a `React.memo` boundary. | Parameter | Type | Default | Description | | --------- | --------- | ------- | -------------------------------------------------------------- | | `enabled` | `boolean` | `true` | Whether the returned trigger fires. Pass a `haptic` prop here. | ### triggerHaptic ```tsx triggerHaptic(intent?: HapticIntent): void; ``` The module-level equivalent, for call sites that are not component bodies — a render prop, a handler defined outside a component, a `runOnJS` target. Both are fire-and-forget: they never throw and never reject. A device with no taptic engine, or a build where the native module is missing, must not be able to take a press handler down with it. ## Platform Differences iOS and web share one API surface. Web is not a no-op — Expo drives the [Web Vibration API](https://caniuse.com/vibration) there, falling back to an iOS-Safari switch-element trick. Android is the reason this hook exists. `impactAsync` and `notificationAsync` are simulated on Android with the raw [`Vibrator`](https://developer.android.com/reference/android/os/Vibrator) service, which Expo explicitly does not recommend: it is a coarse timed buzz, nothing like a native Android control. [`performAndroidHapticsAsync`](https://docs.expo.dev/versions/latest/sdk/haptics/#hapticsperformandroidhapticsasynctype) goes through `View.performHapticFeedback` instead — the same engine those controls use. | Intent | iOS & Web | Android | | --------------- | ---------------------------- | -------------- | | `selection` | `selectionAsync()` | `Segment_Tick` | | `tick` | `selectionAsync()` | `Clock_Tick` | | `toggle-on` | `impactAsync(Light)` | `Toggle_On` | | `toggle-off` | `impactAsync(Light)` | `Toggle_Off` | | `impact-light` | `impactAsync(Light)` | `Virtual_Key` | | `impact-medium` | `impactAsync(Medium)` | `Long_Press` | | `success` | `notificationAsync(Success)` | `Confirm` | | `warning` | `notificationAsync(Warning)` | `Reject` | | `error` | `notificationAsync(Error)` | `Reject` | `tick` maps to `Clock_Tick` rather than `Segment_Frequent_Tick` deliberately: the latter is documented as possibly producing no vibration at all on devices that cannot make a suitably soft one, which would silently drop repeated ticks on cheaper actuators. ## When feedback is silent Haptics can be unavailable for reasons that are not bugs: - **Android** — the user turned off Settings → Sound & vibration → Vibration & haptics → Touch feedback. `performHapticFeedback` is then suppressed system-wide. - **iOS** — Low Power Mode is on, the user disabled the Taptic Engine, or the camera or dictation is active (iOS silences haptics to protect both). - **Web** — the browser does not support the Vibration API, the device has no vibration hardware, or the tab is backgrounded. - **Any platform** — the component was given `haptic={false}`. <!-- ---------------------------------------------------------------------- --> # useKeyboardHeight > A React Native hook that tracks keyboard visibility, height, and animation duration with cross-platform support and screen rotation handling. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/hooks/useKeyboardHeight - Markdown: https://ui.ahmedbna.com/docs/hooks/useKeyboardHeight.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/useKeyboardHeight.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/useKeyboardHeight.json - Install: `npx bna-ui add useKeyboardHeight` --- ## Installation ### CLI ```bash npx bna-ui add useKeyboardHeight ``` ### Manual **1.** Copy and paste the following code into your project. ```ts // hooks/useKeyboardHeight.ts import { useState, useEffect, useRef } from 'react'; import { Keyboard, Platform, Dimensions, KeyboardEvent, EmitterSubscription, } from 'react-native'; interface UseKeyboardHeightReturn { keyboardHeight: number; isKeyboardVisible: boolean; keyboardAnimationDuration: number; } export const useKeyboardHeight = (): UseKeyboardHeightReturn => { const [keyboardHeight, setKeyboardHeight] = useState<number>(0); const [isKeyboardVisible, setIsKeyboardVisible] = useState<boolean>(false); const [keyboardAnimationDuration, setKeyboardAnimationDuration] = useState<number>(0); // Store previous height to handle edge cases const previousHeightRef = useRef<number>(0); useEffect(() => { let showSubscription: EmitterSubscription; let hideSubscription: EmitterSubscription; // Determine which events to listen to based on platform const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; // Handle keyboard show const handleKeyboardShow = (event: KeyboardEvent) => { const { height } = event.endCoordinates; const duration = event.duration; // Validate height - sometimes we get invalid values if (height && height > 0) { setKeyboardHeight(height); setIsKeyboardVisible(true); setKeyboardAnimationDuration(duration || 250); // Default duration if not provided previousHeightRef.current = height; } }; // Handle keyboard hide const handleKeyboardHide = (event: KeyboardEvent) => { setKeyboardHeight(0); setIsKeyboardVisible(false); // Get animation duration (iOS provides this, Android might not) const duration = event.duration || (Platform.OS === 'ios' ? 250 : 200); setKeyboardAnimationDuration(duration); }; // Add event listeners showSubscription = Keyboard.addListener(showEvent, handleKeyboardShow); hideSubscription = Keyboard.addListener(hideEvent, handleKeyboardHide); // Cleanup function return () => { showSubscription.remove(); hideSubscription.remove(); }; }, []); // Additional effect to handle edge cases and screen rotation useEffect(() => { let dimensionSubscription: EmitterSubscription; const handleDimensionChange = () => { // If keyboard was visible and screen rotated, we might need to recalculate if (isKeyboardVisible && previousHeightRef.current > 0) { // On screen rotation, keyboard height might change // This is more relevant for tablets and landscape mode const screenHeight = Dimensions.get('window').height; const screenWidth = Dimensions.get('window').width; // Simple heuristic: if we're in landscape and had a keyboard, // the height might be different if (screenWidth > screenHeight && Platform.OS === 'ios') { // iOS landscape keyboard is typically shorter const estimatedLandscapeHeight = Math.min( previousHeightRef.current, screenHeight * 0.4 ); setKeyboardHeight(estimatedLandscapeHeight); } } }; // Listen to dimension changes (rotation, split screen, etc.) dimensionSubscription = Dimensions.addEventListener( 'change', handleDimensionChange ); return () => { dimensionSubscription?.remove(); }; }, [isKeyboardVisible]); return { keyboardHeight, isKeyboardVisible, keyboardAnimationDuration, }; }; ``` **2.** Update the import paths to match your project setup. ## Usage ```tsx import { useKeyboardHeight } from '@/hooks/useKeyboardHeight'; ``` ```tsx export function KeyboardAwareView({ children }) { const { keyboardHeight, isKeyboardVisible, keyboardAnimationDuration } = useKeyboardHeight(); return ( <View> <Text>Keyboard Height: {keyboardHeight}</Text> <Text>Keyboard Visible: {isKeyboardVisible ? 'Yes' : 'No'}</Text> <Text>Animation Duration: {keyboardAnimationDuration}ms</Text> </View> ); } ``` ## API Reference ### useKeyboardHeight Returns keyboard state information including height, visibility, and animation duration with cross-platform compatibility. #### Parameters This hook takes no parameters. #### Returns | Property | Type | Description | | --------------------------- | --------- | ------------------------------------------------- | | `keyboardHeight` | `number` | Current keyboard height in pixels (0 when hidden) | | `isKeyboardVisible` | `boolean` | Whether the keyboard is currently visible | | `keyboardAnimationDuration` | `number` | Duration of keyboard animation in milliseconds | ## Platform Differences The hook handles platform-specific keyboard events automatically: ### iOS - Uses `keyboardWillShow` and `keyboardWillHide` events for smoother animations - Provides accurate animation duration from the system - Approximates landscape mode keyboard height via an unmeasured heuristic (see below) - Default animation duration: 250ms ### Android - Uses `keyboardDidShow` and `keyboardDidHide` events - May not always provide animation duration (fallback: 200ms) - Less predictable keyboard heights in landscape mode ```tsx // The hook automatically selects the right events const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; ``` ## Advanced Features ### Screen Rotation Support On rotation to landscape on iOS, the hook adjusts the reported keyboard height using an **unmeasured heuristic** — `screenHeight * 0.4`, capped at the last known portrait height — rather than a real measurement from the system. Treat it as a rough approximation for that one case, not a fully supported feature: it doesn't apply on Android, and it can be off for specific devices or third-party keyboards. ```tsx // Approximate landscape keyboard height adjustment on iOS only if (screenWidth > screenHeight && Platform.OS === 'ios') { const estimatedLandscapeHeight = Math.min( previousHeightRef.current, screenHeight * 0.4 // heuristic, not a measured value ); setKeyboardHeight(estimatedLandscapeHeight); } ``` ### Edge Case Handling - **Invalid Heights**: Filters out invalid or zero keyboard heights - **Previous Height Tracking**: Maintains reference to last valid height for calculations - **Dimension Changes**: Responds to screen size changes and split-screen scenarios - **Memory Cleanup**: Properly removes all event listeners on unmount ## Use Cases This hook is perfect for: - Creating keyboard-aware input forms and chat interfaces - Adjusting scroll view content insets when keyboard appears - Animating UI elements in sync with keyboard transitions - Building responsive layouts that adapt to keyboard presence - Implementing custom keyboard avoidance behaviors ## Best Practices ### Performance Optimization Use the hook at the appropriate component level to minimize re-renders: ```tsx // Good: Use in parent component that needs to adjust layout function ChatScreen() { const { keyboardHeight, isKeyboardVisible } = useKeyboardHeight(); return ( <View style={{ flex: 1, paddingBottom: keyboardHeight }}> <MessageList /> <ChatInput /> </View> ); } // Avoid: Using in multiple child components function MessageItem() { const { keyboardHeight } = useKeyboardHeight(); // Unnecessary // ... } ``` ### Safe Area Compatibility Combine with safe area insets for proper spacing: ```tsx import { useSafeAreaInsets } from 'react-native-safe-area-context'; export function SafeKeyboardView({ children }) { const { keyboardHeight, isKeyboardVisible } = useKeyboardHeight(); const insets = useSafeAreaInsets(); const bottomPadding = isKeyboardVisible ? keyboardHeight : insets.bottom; return ( <View style={{ flex: 1, paddingTop: insets.top, paddingBottom: bottomPadding, paddingLeft: insets.left, paddingRight: insets.right, }} > {children} </View> ); } ``` ## Troubleshooting ### Common Issues **Keyboard height is 0 on Android:** - Ensure `android:windowSoftInputMode="adjustResize"` is set in your AndroidManifest.xml - Check that your app is not using `android:windowSoftInputMode="adjustPan"` **Inconsistent behavior in landscape mode:** - The hook includes landscape detection and adjustment for iOS - Android landscape keyboard behavior varies by device and keyboard app **Animation timing doesn't match system keyboard:** - iOS provides accurate animation duration from the system - Android may require manual duration tuning based on your app's needs ### Debug Information Add debug logging to understand keyboard behavior: ```tsx export function DebugKeyboardInfo() { const { keyboardHeight, isKeyboardVisible, keyboardAnimationDuration } = useKeyboardHeight(); return ( <View style={{ position: 'absolute', top: 100, left: 20, backgroundColor: 'rgba(0,0,0,0.8)', padding: 10, }} > <Text style={{ color: 'white' }}>Height: {keyboardHeight}px</Text> <Text style={{ color: 'white' }}> Visible: {isKeyboardVisible ? 'Yes' : 'No'} </Text> <Text style={{ color: 'white' }}> Duration: {keyboardAnimationDuration}ms </Text> <Text style={{ color: 'white' }}>Platform: {Platform.OS}</Text> </View> ); } ``` ## Dependencies - `react` - Required for hooks functionality - `react-native` - Required for Keyboard API and platform detection ## Accessibility The hook supports accessibility by: - Preserving keyboard navigation patterns - Maintaining focus management during keyboard transitions - Supporting screen readers by keeping content visible above keyboard - Enabling proper scrolling behavior for assistive technologies ## References Learn more about keyboard handling in React Native: - [React Native Keyboard API](https://reactnative.dev/docs/keyboard) - [iOS Keyboard Guidelines](https://developer.apple.com/design/human-interface-guidelines/virtual-keyboards) - [Android Soft Input Methods](https://developer.android.com/guide/topics/text/creating-input-method) - [React Navigation Keyboard Handling](https://reactnavigation.org/docs/handling-safe-area/) <!-- ---------------------------------------------------------------------- --> # useModeToggle > A hook that provides complete control over theme mode switching with support for light, dark, and system modes. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/hooks/useModeToggle - Markdown: https://ui.ahmedbna.com/docs/hooks/useModeToggle.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/useModeToggle.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/useModeToggle.json - Install: `npx bna-ui add useModeToggle` - Registry dependencies: `mode-provider` --- ## Installation ### CLI ```bash npx bna-ui add useModeToggle ``` ### Manual **1.** This hook reads the theme mode from ModeProvider. Install it first: ```bash npx bna-ui add mode-provider ``` **2.** Copy and paste the following code into your project. ```tsx // hooks/useModeToggle.tsx import { Mode, useModeContext } from '@/providers/mode-provider'; interface UseModeToggleReturn { isDark: boolean; mode: Mode; setMode: (mode: Mode) => void; currentMode: 'light' | 'dark'; toggleMode: () => void; } /** * Reads and writes the app-wide theme mode held by `ModeProvider`. * * The mode deliberately lives in context rather than in this hook: it used to * be local `useState` paired with a global `Appearance.setColorScheme` call, so * remounting the toggle reset the cycle to `'system'` while the app stayed * dark, and two toggles on screen disagreed. Sharing the state also makes the * toggle work on web, where `Appearance` is read-only. */ export function useModeToggle(): UseModeToggleReturn { const context = useModeContext(); if (!context) { throw new Error( 'useModeToggle requires a <ModeProvider>. Wrap your app in the ' + '<ThemeProvider> in providers/theme-provider, which mounts one, or ' + 'mount <ModeProvider> from providers/mode-provider yourself.' ); } const { mode, setMode, scheme } = context; const toggleMode = () => { switch (mode) { case 'light': setMode('dark'); break; case 'dark': setMode('system'); break; case 'system': setMode('light'); break; } }; return { isDark: scheme === 'dark', mode, setMode, currentMode: scheme, toggleMode, }; } ``` **3.** Update the import paths to match your project setup. ## Requirements The mode lives in [`ModeProvider`](/docs/providers/mode-provider), not in this hook, so one has to be mounted above every component that calls `useModeToggle`. The [`ThemeProvider`](/docs/providers/theme-provider) you already wrap your app in mounts it for you — if you use that, there is nothing to do: ```tsx import { ThemeProvider } from '@/providers/theme-provider'; export default function RootLayout() { return ( <ThemeProvider> <Stack /> </ThemeProvider> ); } ``` Calling the hook without a provider throws rather than silently doing nothing. Sharing the state this way is what makes the toggle behave: every toggle in the app agrees on the current mode, remounting a screen no longer resets the cycle, and the theme changes on web as well as native. ## Usage ```tsx import { useModeToggle } from '@/hooks/useModeToggle'; ``` ```tsx export function ThemeToggle() { const { isDark, mode, setMode, toggleMode } = useModeToggle(); return ( <View> <Text>Current mode: {mode}</Text> <Text>Is dark: {isDark ? 'Yes' : 'No'}</Text> <TouchableOpacity onPress={toggleMode}> <Text>Toggle Theme</Text> </TouchableOpacity> </View> ); } ``` ## API Reference ### useModeToggle A hook that provides complete control over theme mode switching with support for light, dark, and system modes. #### Returns | Property | Type | Description | | ------------- | ------------------------------- | -------------------------------------------------------------- | | `isDark` | `boolean` | Whether the current effective theme is dark | | `mode` | `'light' \| 'dark' \| 'system'` | The currently selected mode setting | | `setMode` | `(mode: Mode) => void` | Function to set a specific mode | | `currentMode` | `'light' \| 'dark'` | The resolved color scheme, with `'system'` already applied | | `toggleMode` | `() => void` | Function to cycle through modes: light → dark → system → light | #### Type Definitions ```tsx type Mode = 'light' | 'dark' | 'system'; interface UseModeToggleReturn { isDark: boolean; mode: Mode; setMode: (mode: Mode) => void; currentMode: 'light' | 'dark'; toggleMode: () => void; } ``` ## Mode Behavior Setting a mode writes it to `ModeProvider`, which every `useColorScheme` call in the app reads — that is what repaints your components. On native the choice is additionally mirrored into React Native's global `Appearance`, so the status bar, the Android navigation bar and native sheets follow along. ### Light Mode - Forces the app to use light theme regardless of system preference - `isDark` returns `false` - `currentMode` returns `'light'` ### Dark Mode - Forces the app to use dark theme regardless of system preference - `isDark` returns `true` - `currentMode` returns `'dark'` ### System Mode - Follows the system's color scheme preference - `isDark` reflects the actual system preference - `currentMode` returns the system's current preference ## Toggle Cycle The `toggleMode` function cycles through modes in this order: ``` light → dark → system → light → ... ``` This provides an intuitive way for users to quickly switch between all available options. ## Use Cases This hook is perfect for: - Creating theme toggle buttons in settings screens - Building comprehensive theme selection interfaces - Implementing persistent theme preferences - Providing users with granular control over app appearance - Creating theme-aware components that need to know the current mode - Building accessibility-compliant theme switching ## Best Practices ### Settings Screen Implementation Create a comprehensive settings screen with theme options: ```tsx export function ThemeSettings() { const { mode, setMode, isDark, currentMode } = useModeToggle(); const options = [ { key: 'light', label: 'Light', icon: '☀️' }, { key: 'dark', label: 'Dark', icon: '🌙' }, { key: 'system', label: 'System', icon: '⚙️' }, ]; return ( <View style={{ padding: 20 }}> <Text style={{ fontSize: 18, marginBottom: 16 }}>Theme</Text> {options.map((option) => ( <TouchableOpacity key={option.key} style={{ flexDirection: 'row', alignItems: 'center', paddingVertical: 12, backgroundColor: mode === option.key ? '#007AFF20' : 'transparent', borderRadius: 8, paddingHorizontal: 12, }} onPress={() => setMode(option.key as Mode)} > <Text style={{ marginRight: 12 }}>{option.icon}</Text> <Text style={{ flex: 1 }}>{option.label}</Text> {mode === option.key && <Text>✓</Text>} </TouchableOpacity> ))} <Text style={{ marginTop: 16, opacity: 0.6 }}> Current: {currentMode} (Effective: {isDark ? 'dark' : 'light'}) </Text> </View> ); } ``` ### Persistent Theme Storage Persistence is a prop on the provider, not something to wrap this hook in. Pass any key/value store — `expo-secure-store` matches the shape as-is, and is what every `npx bna-ui init` scaffold uses: ```tsx import * as SecureStore from 'expo-secure-store'; import { ThemeProvider } from '@/providers/theme-provider'; export default function RootLayout() { return ( <ThemeProvider storage={SecureStore}> <Stack /> </ThemeProvider> ); } ``` The mode is restored on mount and written on every change. A missing or unreadable value falls back to `defaultMode`, so a failed read can never block startup — including on web, where SecureStore is unavailable and persistence simply no-ops. See [`ModeProvider`](/docs/providers/mode-provider) for the full contract. ### Animated Theme Toggle Create smooth transitions between theme modes: ```tsx import { useEffect, useRef } from 'react'; import { Animated } from 'react-native'; export function AnimatedThemeToggle() { const { isDark, toggleMode } = useModeToggle(); const animatedValue = useRef(new Animated.Value(isDark ? 1 : 0)).current; useEffect(() => { Animated.timing(animatedValue, { toValue: isDark ? 1 : 0, duration: 300, useNativeDriver: false, }).start(); }, [isDark]); const backgroundColor = animatedValue.interpolate({ inputRange: [0, 1], outputRange: ['#ffffff', '#000000'], }); return ( <Animated.View style={{ backgroundColor, flex: 1 }}> <TouchableOpacity onPress={toggleMode}> <Text>Toggle Theme</Text> </TouchableOpacity> </Animated.View> ); } ``` ### Header Integration Integrate theme toggle into your app header: ```tsx export function AppHeader() { const { mode, toggleMode } = useModeToggle(); const getToggleIcon = () => { switch (mode) { case 'light': return '☀️'; case 'dark': return '🌙'; case 'system': return '⚙️'; } }; return ( <View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', padding: 16, }} > <Text style={{ fontSize: 18, fontWeight: 'bold' }}>My App</Text> <TouchableOpacity onPress={toggleMode}> <Text style={{ fontSize: 20 }}>{getToggleIcon()}</Text> </TouchableOpacity> </View> ); } ``` ## Performance Considerations ### Memoization The hook uses internal state management and is already optimized, but you can memoize dependent calculations: ```tsx const themeStyles = useMemo( () => ({ container: { backgroundColor: isDark ? '#000' : '#fff', color: isDark ? '#fff' : '#000', }, }), [isDark] ); ``` ### Avoiding Unnecessary Re-renders Only destructure the values you actually need: ```tsx // Good: Only get what you need const { isDark, toggleMode } = useModeToggle(); // Less optimal: Getting all values when you only need some const modeToggle = useModeToggle(); ``` ## Advanced Usage ### Custom Mode Validation Add validation for supported modes: ```tsx export function useValidatedModeToggle() { const modeToggle = useModeToggle(); const setModeWithValidation = (mode: string) => { if (['light', 'dark', 'system'].includes(mode)) { modeToggle.setMode(mode as Mode); } else { console.warn(`Invalid mode: ${mode}`); } }; return { ...modeToggle, setMode: setModeWithValidation, }; } ``` ### Reading the Mode Without the Toggle You do not need a context of your own — the mode already lives in one. For components that only read the theme, use [`useColorScheme`](/docs/hooks/useColorScheme) or [`useColor`](/docs/hooks/useColor); both resolve through the same provider. To read or set the mode without the light → dark → system cycle, reach for [`useModeContext`](/docs/providers/mode-provider): ```tsx import { useModeContext } from '@/providers/mode-provider'; export function ThemeLabel() { const { mode, scheme } = useModeContext() ?? {}; return ( <Text> {mode} (rendering as {scheme}) </Text> ); } ``` ## Dependencies - `@/providers/mode-provider` - Holds the mode and resolves it to a scheme - `react` - Required for context ## Accessibility The hook supports accessibility by: - Providing programmatic access to theme state for screen readers - Enabling proper contrast ratios through theme mode control - Supporting system-level accessibility preferences in system mode - Allowing users to override system preferences when needed ## Platform Support All three modes work on every platform, because the mode is held in React context rather than pushed through a platform API. ### iOS - System mode respects iOS system preferences - The choice is mirrored into `Appearance.setColorScheme`, so the status bar and native sheets follow ### Android - System mode respects Android system preferences - The choice is mirrored into `Appearance.setColorScheme`, so the status bar and the navigation bar follow ### Web - System mode detection works through `useColorScheme` - react-native-web's `Appearance` is read-only — it has `getColorScheme` and `addChangeListener` but no setter — so the provider is the only thing driving the theme here. That is by design, and why the toggle works on web ## Related Hooks - [`useColorScheme`](/docs/hooks/useColorScheme) - Base hook for theme detection - [`useColor`](/docs/hooks/useColor) - For color resolution based on theme - [`ModeProvider`](/docs/providers/mode-provider) - Holds the mode this hook reads and writes <!-- ---------------------------------------------------------------------- --> # Providers > The React context providers you mount at the root of your app. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/providers - Markdown: https://ui.ahmedbna.com/docs/providers.md --- - [ModeProvider](/docs/providers/mode-provider) - [ThemeProvider](/docs/providers/theme-provider) <!-- ---------------------------------------------------------------------- --> # ModeProvider > Holds the app-wide light, dark or system theme mode, resolves it to a concrete color scheme, and optionally persists it. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/providers/mode-provider - Markdown: https://ui.ahmedbna.com/docs/providers/mode-provider.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/mode-provider.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/mode-provider.json - Install: `npx bna-ui add mode-provider` --- ## Installation ### CLI ```bash npx bna-ui add mode-provider ``` ### Manual **1.** This file has no dependencies beyond React and React Native. **2.** Copy and paste the following code into your project. ```tsx // providers/mode-provider.tsx import { createContext, useCallback, useContext, useEffect, useMemo, useState, } from 'react'; import { Appearance, useColorScheme as useRNColorScheme } from 'react-native'; export type Mode = 'light' | 'dark' | 'system'; /** * The slice of a key/value store this needs. Returns are sync-or-async so a * store satisfies it whichever style it exposes — `expo-secure-store` (sync * `getItem`/`setItem`) and `AsyncStorage` (promise-returning) both pass * unchanged, with no adapter. * * Keeping it structural is what lets persistence cost an app one prop and this * package zero dependencies — nobody installing `useColor` pays for a storage * engine they may not want. */ export type ModeStorage = { getItem: (key: string) => string | null | Promise<string | null>; setItem: (key: string, value: string) => void | Promise<void>; }; type ModeContextValue = { /** What the app was asked for, including the `'system'` passthrough. */ mode: Mode; setMode: (mode: Mode) => void; /** * What the app should actually render as. Prefer this over re-deriving it * from `mode` — outside `'system'` there is no meaningful system value to * fall back to, and on native RN's own `useColorScheme()` reports the * override rather than the OS once `setMode` has run. */ scheme: 'light' | 'dark'; }; const ModeContext = createContext<ModeContextValue | null>(null); const isMode = (value: unknown): value is Mode => value === 'light' || value === 'dark' || value === 'system'; /** * Mirrors the override into React Native's global `Appearance` so native chrome * follows the toggle too — the status bar, the Android navigation bar, native * sheet presentation, and anything reading the OS scheme *above* this provider * (root layouts do exactly that to colour the system UI). * * `Appearance.setColorScheme` landed in React Native 0.73 and react-native-web * has never implemented it, so feature-detect rather than assume. On web the * context alone drives the theme, which is why the toggle works there without * this call. */ function syncNativeAppearance(mode: Mode) { if (typeof Appearance.setColorScheme !== 'function') return; // RN 0.86 replaced the old `null` sentinel ("follow the system") with // `'unspecified'`. Appearance.setColorScheme(mode === 'system' ? 'unspecified' : mode); } type Props = { children: React.ReactNode; /** Supply to persist the choice across launches. Omit and it resets. */ storage?: ModeStorage; storageKey?: string; defaultMode?: Mode; }; export const ModeProvider = ({ children, storage, storageKey = 'bna-ui.mode', defaultMode = 'system', }: Props) => { const [mode, setModeState] = useState<Mode>(defaultMode); const systemScheme = useRNColorScheme() === 'dark' ? 'dark' : 'light'; // Rehydrate once. A missing, malformed or unreadable value leaves the default // in place — persistence is a convenience and must never be able to break boot. // // The `Promise.resolve().then(...)` wrapper is doing real work: it normalises // sync and async stores into one path, and turns a *synchronous* throw into a // rejection `.catch` can see. `expo-secure-store` throws exactly that way on // web, where it is unsupported. useEffect(() => { if (!storage) return; let cancelled = false; Promise.resolve() .then(() => storage.getItem(storageKey)) .then((saved) => { if (cancelled || !isMode(saved)) return; setModeState(saved); syncNativeAppearance(saved); }) .catch(() => {}); return () => { cancelled = true; }; }, [storage, storageKey]); const setMode = useCallback( (next: Mode) => { setModeState(next); syncNativeAppearance(next); if (storage) { Promise.resolve() .then(() => storage.setItem(storageKey, next)) .catch(() => {}); } }, [storage, storageKey] ); // This sits at the app root, so an unstable value re-renders every themed // component in the tree on any parent render. const value = useMemo<ModeContextValue>( () => ({ mode, setMode, scheme: mode === 'system' ? systemScheme : mode, }), [mode, setMode, systemScheme] ); return <ModeContext.Provider value={value}>{children}</ModeContext.Provider>; }; /** * `null` when no `ModeProvider` is mounted, so callers can fall back to the * system scheme instead of forcing every app that only wanted `useColor` to * mount a provider. */ export function useModeContext(): ModeContextValue | null { return useContext(ModeContext); } ``` **3.** Update the import paths to match your project setup. ## Usage [`ThemeProvider`](/docs/providers/theme-provider) mounts this for you, so most apps never import it directly — and every scaffold from `npx bna-ui init` is already set up. Mount it yourself only if you are not using `ThemeProvider`: ```tsx import { ModeProvider } from '@/providers/mode-provider'; export default function RootLayout() { return ( <ModeProvider> <Stack /> </ModeProvider> ); } ``` Everything below it — [`useColorScheme`](/docs/hooks/useColorScheme), [`useColor`](/docs/hooks/useColor), and therefore every component in the library — resolves its colors through this provider. ### Persisting the Choice Pass any key/value store. `expo-secure-store` — what every scaffold from `npx bna-ui init` uses — already matches the shape: ```tsx import * as SecureStore from 'expo-secure-store'; import { ThemeProvider } from '@/providers/theme-provider'; export default function RootLayout() { return ( <ThemeProvider storage={SecureStore}> <Stack /> </ThemeProvider> ); } ``` SecureStore has no web implementation, so on web this degrades to no persistence rather than erroring — the toggle itself still works there. Reach for `AsyncStorage` instead if persisting on web matters more to you than using the store the scaffolds already depend on. The mode is restored once on mount and written on every change. Nothing is persisted unless you pass `storage`, which is why this file adds no dependencies — you choose the storage engine, and apps that do not want one do not pay for it. A missing, malformed or unreadable value falls back to `defaultMode`, and a store that throws — including one that is simply unavailable on the current platform — is swallowed, so persistence can never block startup. ## API Reference ### ModeProvider Holds the app-wide theme mode (`light`, `dark` or `system`) and resolves it to a concrete scheme. `useColorScheme` reads it when mounted, so an in-app toggle works on native _and_ web — react-native-web has no `Appearance.setColorScheme` to write an override through. On native the choice is also mirrored into React Native `Appearance`, so the status bar and Android navigation bar follow. `ThemeProvider` mounts this for you. | Prop | Type | Required | Description | | ------------- | ------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `children` | `React.ReactNode` | Yes | The wrapped app content. | | `storage` | `ModeStorage` | No | A key/value store — `{ getItem, setItem }`, sync or async, which `expo-secure-store` and `AsyncStorage` both satisfy as-is — used to persist the choice across launches. Omit it and the mode resets to `defaultMode` on every launch. A missing, malformed or unreadable value falls back to `defaultMode`, so a store that is unavailable on the current platform degrades to no persistence rather than an error. | | `storageKey` | `string` | No | Key the mode is persisted under. Defaults to `bna-ui.mode`. | | `defaultMode` | `'light' \| 'dark' \| 'system'` | No | Mode used before anything is restored from storage. Defaults to `system`. | ### useModeContext Returns `{ mode, setMode, scheme }`, or `null` when no `ModeProvider` is mounted — which is what lets `useColor` work without one. `scheme` is the resolved `light`/`dark` value; prefer it over re-deriving from `mode`. Most apps want `useModeToggle` instead. | Prop | Type | Description | | ---- | ---- | ----------- | ### ModeStorage ```tsx type ModeStorage = { getItem: (key: string) => string | null | Promise<string | null>; setItem: (key: string, value: string) => void | Promise<void>; }; ``` Deliberately the smallest surface that does the job, and sync-or-async on both methods, so `expo-secure-store` (sync `getItem`/`setItem`), `AsyncStorage` (promise-returning) and MMKV adapters all satisfy it with no shim. ## How It Works The provider keeps the requested `mode` in state and derives `scheme` from it — resolving `'system'` against React Native's `useColorScheme()`. Reading `scheme` rather than re-deriving from `mode` matters: outside system mode there is no meaningful system value to fall back to. On native, `setMode` additionally mirrors the choice into React Native's global `Appearance`, so UI that sits _above_ this provider follows too — the status bar, the Android navigation bar, native sheet presentation, and root layouts that read the OS scheme to color system chrome. That call is feature-detected. `Appearance.setColorScheme` landed in React Native 0.73 and react-native-web has never implemented it — on web `Appearance` is read-only, offering `getColorScheme` and `addChangeListener` and no setter. The context is what drives the theme there, which is why the toggle works on web at all. ## Why Not Local State The mode used to live in `useModeToggle` as local `useState` next to a global `Appearance.setColorScheme` call. Half the state was per-component and half was app-wide, which broke in three ways: - **On web, nothing happened.** There was no setter to write the override through, so pressing the toggle changed the icon and left every color alone. - **Remounting reset the cycle.** Navigate away from a screen holding the toggle and back, and `mode` returned to `'system'` while the app stayed dark — so the next press jumped to the wrong mode. - **Two toggles disagreed.** Each call site had its own independent `mode`. Moving the state into context fixes all three, and makes persistence a single prop rather than a hook every app has to write for itself. ## Related - [`useModeToggle`](/docs/hooks/useModeToggle) - Cycles light → dark → system - [`ThemeProvider`](/docs/providers/theme-provider) - Mounts this provider for you - [`useColorScheme`](/docs/hooks/useColorScheme) - Reads the resolved scheme - [`ModeToggle`](/docs/components/mode-toggle) - The animated toggle button <!-- ---------------------------------------------------------------------- --> # ThemeProvider > A context provider that manages theme state and provides consistent theming across your React Native app with React Navigation integration. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/providers/theme-provider - Markdown: https://ui.ahmedbna.com/docs/providers/theme-provider.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/theme-provider.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/theme-provider.json - Install: `npx bna-ui add theme-provider` - npm dependencies: `expo-router` - Registry dependencies: `mode-provider`, `useColorScheme`, `colors` --- ## Installation ### CLI ```bash npx bna-ui add theme-provider ``` ### Manual **1.** This component mounts ModeProvider. Install it first: ```bash npx bna-ui add mode-provider ``` **2.** Beyond that it only uses expo-router/react-navigation (re-exporting ThemeProvider, DefaultTheme, and DarkTheme), so no additional dependencies are needed beyond expo-router itself. **3.** Copy and paste the following code into your project. ```tsx // providers/theme-provider.tsx import { DarkTheme, DefaultTheme, ThemeProvider as RNThemeProvider, } from 'expo-router/react-navigation'; import { useMemo } from 'react'; import { useColorScheme } from '@/hooks/useColorScheme'; import { Colors } from '@/theme/colors'; import { Mode, ModeProvider, ModeStorage } from '@/providers/mode-provider'; type Props = { children: React.ReactNode; /** Supply to persist the theme choice across launches. Omit and it resets. */ storage?: ModeStorage; storageKey?: string; defaultMode?: Mode; }; /** * Mounts `ModeProvider` — the app-wide source of truth for light/dark/system — * and maps the resolved scheme onto React Navigation's theme. * * The navigation half is a separate component because it calls * `useColorScheme()`, which has to read that context from *inside* the provider. */ export const ThemeProvider = ({ children, storage, storageKey, defaultMode, }: Props) => ( <ModeProvider storage={storage} storageKey={storageKey} defaultMode={defaultMode} > <NavigationTheme>{children}</NavigationTheme> </ModeProvider> ); const NavigationTheme = ({ children }: { children: React.ReactNode }) => { const colorScheme = useColorScheme(); // Rebuilding this on every render invalidates every useTheme() consumer // app-wide, since ThemeProvider is mounted at the root — memoize on the // one thing it actually depends on, and only build the active theme. const theme = useMemo(() => { if (colorScheme === 'dark') { return { ...DarkTheme, colors: { ...DarkTheme.colors, primary: Colors.dark.primary, background: Colors.dark.background, card: Colors.dark.card, text: Colors.dark.text, border: Colors.dark.border, notification: Colors.dark.red, }, }; } return { ...DefaultTheme, colors: { ...DefaultTheme.colors, primary: Colors.light.primary, background: Colors.light.background, card: Colors.light.card, text: Colors.light.text, border: Colors.light.border, notification: Colors.light.red, }, }; }, [colorScheme]); return <RNThemeProvider value={theme}>{children}</RNThemeProvider>; }; ``` **4.** Update the import paths to match your project setup. ## Usage ### Basic Setup Wrap your app with the `ThemeProvider` at the root level, typically in your `App.tsx` or `_layout.tsx` file. ```tsx import { ThemeProvider } from '@/providers/theme-provider'; import { NavigationContainer } from '@react-navigation/native'; export default function App() { return ( <ThemeProvider> <NavigationContainer> {/* Your navigation components */} </NavigationContainer> </ThemeProvider> ); } ``` ### With Expo Router ```tsx import { ThemeProvider } from '@/providers/theme-provider'; import { Stack } from 'expo-router'; export default function RootLayout() { return ( <ThemeProvider> <Stack> <Stack.Screen name='index' /> <Stack.Screen name='about' /> </Stack> </ThemeProvider> ); } ``` ## API Reference ### ThemeProvider The main provider component that manages theme state and React Navigation theming. #### Props | Prop | Type | Required | Description | | ------------- | ------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `children` | `React.ReactNode` | Yes | Child components to be wrapped by the provider | | `storage` | `ModeStorage` | No | Key/value store used to persist the mode, sync or async. `expo-secure-store` and `AsyncStorage` both fit as-is. Omit and the mode resets | | `storageKey` | `string` | No | Key the mode is persisted under. Defaults to `bna-ui.mode` | | `defaultMode` | `'light' \| 'dark' \| 'system'` | No | Mode used before anything is restored from storage. Defaults to `system` | The last three are forwarded straight to [`ModeProvider`](/docs/providers/mode-provider). #### Returns Provides themed React Navigation context to all child components. ## How It Works The `ThemeProvider` automatically: 1. **Mounts `ModeProvider`**: Holds the app-wide light/dark/system mode, so [`useModeToggle`](/docs/hooks/useModeToggle) works anywhere below it 2. **Resolves the Theme**: Uses the `useColorScheme` hook, which prefers that mode over the raw system scheme 3. **Creates Custom Themes**: Builds React Navigation themes using your custom color system 4. **Provides Theme Context**: Makes the theme available to all React Navigation components 5. **Handles Theme Changes**: Automatically updates when the system theme changes ### Theme Mapping The provider maps your custom colors to React Navigation's theme structure: #### Light Theme Mapping ```tsx const customLightTheme = { ...DefaultTheme, colors: { ...DefaultTheme.colors, primary: Colors.light.primary, // #18181b background: Colors.light.background, // #FFFFFF card: Colors.light.card, // #F2F2F7 text: Colors.light.text, // #000000 border: Colors.light.border, // #C6C6C8 notification: Colors.light.red, // #FF3B30 }, }; ``` #### Dark Theme Mapping ```tsx const customDarkTheme = { ...DarkTheme, colors: { ...DarkTheme.colors, primary: Colors.dark.primary, // #e4e4e7 background: Colors.dark.background, // #000000 card: Colors.dark.card, // #1C1C1E text: Colors.dark.text, // #FFFFFF border: Colors.dark.border, // #38383A notification: Colors.dark.red, // #FF453A }, }; ``` ## Usage Examples ### Basic App Structure ```tsx import { ThemeProvider } from '@/providers/theme-provider'; import { NavigationContainer } from '@react-navigation/native'; import { createStackNavigator } from '@react-navigation/stack'; import { HomeScreen } from '@/screens/HomeScreen'; import { SettingsScreen } from '@/screens/SettingsScreen'; const Stack = createStackNavigator(); export default function App() { return ( <ThemeProvider> <NavigationContainer> <Stack.Navigator> <Stack.Screen name='Home' component={HomeScreen} /> <Stack.Screen name='Settings' component={SettingsScreen} /> </Stack.Navigator> </NavigationContainer> </ThemeProvider> ); } ``` ### With Bottom Tab Navigation ```tsx import { ThemeProvider } from '@/providers/theme-provider'; import { NavigationContainer } from '@react-navigation/native'; import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import { HomeScreen } from '@/screens/HomeScreen'; import { ProfileScreen } from '@/screens/ProfileScreen'; const Tab = createBottomTabNavigator(); export default function App() { return ( <ThemeProvider> <NavigationContainer> <Tab.Navigator> <Tab.Screen name='Home' component={HomeScreen} /> <Tab.Screen name='Profile' component={ProfileScreen} /> </Tab.Navigator> </NavigationContainer> </ThemeProvider> ); } ``` ### Accessing Theme in Components ```tsx import { useTheme } from '@react-navigation/native'; export function ThemedComponent() { const { colors } = useTheme(); return ( <View style={{ backgroundColor: colors.background }}> <Text style={{ color: colors.text }}> This text automatically adapts to the theme </Text> </View> ); } ``` ### Custom Screen with Theme ```tsx import { useTheme } from '@react-navigation/native'; import { Colors } from '@/theme/colors'; import { useColorScheme } from '@/hooks/useColorScheme'; export function CustomScreen() { const navigationTheme = useTheme(); const colorScheme = useColorScheme(); const colors = Colors[colorScheme ?? 'light']; return ( <View style={{ flex: 1, backgroundColor: navigationTheme.colors.background, }} > <View style={{ backgroundColor: colors.card, padding: 16, margin: 16, borderRadius: 12, }} > <Text style={{ color: colors.text }}> Using custom colors alongside React Navigation theme </Text> </View> </View> ); } ``` ## Advanced Usage ### Custom Theme Extension You can extend the theme provider to include additional theme properties: ```tsx import { ThemeProvider as BaseThemeProvider } from '@/providers/theme-provider'; import { createContext, useContext } from 'react'; const CustomThemeContext = createContext({}); export function ExtendedThemeProvider({ children }) { const customTheme = { shadows: { small: { shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 4, }, }, animations: { duration: 300, }, }; return ( <BaseThemeProvider> <CustomThemeContext.Provider value={customTheme}> {children} </CustomThemeContext.Provider> </BaseThemeProvider> ); } export const useCustomTheme = () => useContext(CustomThemeContext); ``` ### Theme-Aware StatusBar ```tsx import { ThemeProvider } from '@/providers/theme-provider'; import { useColorScheme } from '@/hooks/useColorScheme'; import { StatusBar } from 'expo-status-bar'; export default function App() { const colorScheme = useColorScheme(); return ( <ThemeProvider> <StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} /> {/* Your app content */} </ThemeProvider> ); } ``` ## Best Practices ### Provider Hierarchy Place the `ThemeProvider` at the highest level possible to ensure all components have access to the theme. ```tsx // ✅ Good - at the root level export default function App() { return ( <ThemeProvider> <SafeAreaProvider> <NavigationContainer>{/* App content */}</NavigationContainer> </SafeAreaProvider> </ThemeProvider> ); } // ❌ Bad - nested too deep export default function App() { return ( <NavigationContainer> <SomeOtherProvider> <ThemeProvider>{/* Limited theme access */}</ThemeProvider> </SomeOtherProvider> </NavigationContainer> ); } ``` ### Consistent Theme Usage Use React Navigation's `useTheme` hook for navigation-related styling and your custom Colors for other components. ```tsx import { useTheme } from '@react-navigation/native'; import { Colors } from '@/theme/colors'; import { useColorScheme } from '@/hooks/useColorScheme'; export function ConsistentComponent() { const navigationTheme = useTheme(); const colorScheme = useColorScheme(); const customColors = Colors[colorScheme ?? 'light']; return ( <View style={{ backgroundColor: navigationTheme.colors.background }}> {/* Navigation-related elements use navigation theme */} <Text style={{ color: navigationTheme.colors.text }}> Navigation Text </Text> {/* Custom UI elements use custom colors */} <View style={{ backgroundColor: customColors.accent }}> <Text style={{ color: customColors.accentForeground }}> Custom Accent Element </Text> </View> </View> ); } ``` ### Performance Optimization The theme provider automatically handles theme changes efficiently. Avoid creating theme objects inside render functions. ```tsx // ✅ Good - theme objects created once const customLightTheme = { ...DefaultTheme, colors: { ...DefaultTheme.colors, primary: Colors.light.primary, // ... other colors }, }; // ❌ Bad - recreating theme objects on each render export function BadThemeProvider({ children }) { const colorScheme = useColorScheme(); const theme = { ...DefaultTheme, colors: { ...DefaultTheme.colors, primary: Colors[colorScheme].primary, // Recreated every render }, }; return <RNThemeProvider value={theme}>{children}</RNThemeProvider>; } ``` ## Integration with Other Libraries ### React Native Elements ```tsx import { ThemeProvider } from '@/providers/theme-provider'; import { ThemeProvider as ElementsThemeProvider } from 'react-native-elements'; import { useColorScheme } from '@/hooks/useColorScheme'; import { Colors } from '@/theme/colors'; export function CombinedThemeProvider({ children }) { const colorScheme = useColorScheme(); const colors = Colors[colorScheme ?? 'light']; const elementsTheme = { colors: { primary: colors.primary, secondary: colors.secondary, success: colors.green, warning: colors.orange, error: colors.red, }, }; return ( <ThemeProvider> <ElementsThemeProvider theme={elementsTheme}> {children} </ElementsThemeProvider> </ThemeProvider> ); } ``` ### Styled Components ```tsx import styled, { ThemeProvider as StyledThemeProvider, } from 'styled-components/native'; import { ThemeProvider } from '@/providers/theme-provider'; import { useColorScheme } from '@/hooks/useColorScheme'; import { Colors } from '@/theme/colors'; export function StyledThemeProvider({ children }) { const colorScheme = useColorScheme(); const colors = Colors[colorScheme ?? 'light']; return ( <ThemeProvider> <StyledThemeProvider theme={{ colors }}>{children}</StyledThemeProvider> </ThemeProvider> ); } // Usage in styled components const StyledView = styled.View` background-color: ${(props) => props.theme.colors.background}; padding: 16px; `; ``` ## Troubleshooting ### Theme Not Updating If your theme isn't updating when the system theme changes: 1. Ensure the `ThemeProvider` is at the root level 2. Check that you're using the `useColorScheme` hook correctly 3. Verify React Navigation is properly wrapped ```tsx // Make sure this structure is correct <ThemeProvider> <NavigationContainer>{/* Your screens */}</NavigationContainer> </ThemeProvider> ``` ### Colors Not Matching If colors don't match between navigation and custom components: 1. Check that you're importing the correct Colors object 2. Ensure the color scheme detection is consistent 3. Verify the color mapping in the theme provider ```tsx // Check these imports are correct import { Colors } from '@/theme/colors'; import { useColorScheme } from '@/hooks/useColorScheme'; ``` ### Performance Issues If you experience performance issues with theme switching: 1. Avoid creating theme objects in render functions 2. Use React.memo for components that don't need frequent re-renders 3. Consider using a theme cache if you have complex theme calculations ```tsx const ThemedComponent = React.memo(({ data }) => { const { colors } = useTheme(); return ( <View style={{ backgroundColor: colors.background }}> {/* Component content */} </View> ); }); ``` ## Dependencies ### Required Dependencies - `expo-router`: Provides the `ThemeProvider`/`DefaultTheme`/`DarkTheme` re-exports this component builds on ### Optional Dependencies Only needed if you use the React Navigation-specific examples above (raw `NavigationContainer` outside of `expo-router`, `useTheme`, stack/tab navigators): - `@react-navigation/native`: Core navigation library - `react-native-safe-area-context`: For safe area handling - `@react-navigation/stack`: For stack navigation - `@react-navigation/bottom-tabs`: For tab navigation ## Platform Compatibility The `ThemeProvider` works across all platforms supported by React Native: - iOS - Android - Web (React Native Web) - Windows (React Native Windows) - macOS (React Native macOS) The theme automatically adapts to platform-specific color schemes and follows system-level appearance settings. ## Accessibility The theme provider enhances accessibility by: - Respecting system-level dark mode preferences - Providing consistent color contrast ratios - Supporting high contrast mode when available - Maintaining semantic color meanings across themes Users with visual impairments benefit from the automatic theme switching and consistent color usage throughout the app. <!-- ---------------------------------------------------------------------- --> # Theme > Here you can find all the theme files available in the library. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/theme - Markdown: https://ui.ahmedbna.com/docs/theme.md --- - [Colors](/docs/theme/colors) - [Globals](/docs/theme/globals) <!-- ---------------------------------------------------------------------- --> # Colors > A comprehensive color system with light and dark mode support, semantic colors, and utility functions for consistent theming across your React Native app. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/theme/colors - Markdown: https://ui.ahmedbna.com/docs/theme/colors.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/colors.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/colors.json - Install: `npx bna-ui add colors` --- ## Installation ### CLI ```bash npx bna-ui add colors ``` ### Manual **1.** Copy and paste the following code into your project. ```ts // theme/colors.ts const lightColors = { // Base colors background: '#FFFFFF', foreground: '#000000', // Card colors card: '#F2F2F7', cardForeground: '#000000', // Popover colors popover: '#F2F2F7', popoverForeground: '#000000', // Primary colors primary: '#18181b', primaryForeground: '#FFFFFF', // Secondary colors secondary: '#F2F2F7', secondaryForeground: '#18181b', // Muted colors muted: '#78788033', mutedForeground: '#71717a', // Accent colors accent: '#F2F2F7', accentForeground: '#18181b', // Destructive colors destructive: '#ef4444', destructiveForeground: '#FFFFFF', // Border and input border: '#C6C6C8', input: '#e4e4e7', ring: '#a1a1aa', // Text colors text: '#000000', textMuted: '#71717a', // Legacy support for existing components tint: '#18181b', icon: '#71717a', tabIconDefault: '#71717a', tabIconSelected: '#18181b', // Default buttons, links, Send button, selected tabs blue: '#007AFF', // Success states, FaceTime buttons, completed tasks green: '#34C759', // Delete buttons, error states, critical alerts red: '#FF3B30', // VoiceOver highlights, warning states orange: '#FF9500', // Notes app accent, Reminders highlights yellow: '#FFCC00', // Pink accent color for various UI elements pink: '#FF2D92', // Purple accent for creative apps and features purple: '#AF52DE', // Teal accent for communication features teal: '#5AC8FA', // Indigo accent for system features indigo: '#5856D6', // Semantic states success: '#22c55e', successForeground: '#ffffff', warning: '#f59e0b', warningForeground: '#ffffff', info: '#3b82f6', infoForeground: '#ffffff', error: '#ef4444', errorForeground: '#ffffff', }; const darkColors = { // Base colors background: '#000000', foreground: '#FFFFFF', // Card colors card: '#1C1C1E', cardForeground: '#FFFFFF', // Popover colors popover: '#18181b', popoverForeground: '#FFFFFF', // Primary colors primary: '#e4e4e7', primaryForeground: '#18181b', // Secondary colors secondary: '#1C1C1E', secondaryForeground: '#FFFFFF', // Muted colors muted: '#78788033', mutedForeground: '#a1a1aa', // Accent colors accent: '#1C1C1E', accentForeground: '#FFFFFF', // Destructive colors destructive: '#dc2626', destructiveForeground: '#FFFFFF', // Border and input - using alpha values for better blending border: '#38383A', input: 'rgba(255, 255, 255, 0.15)', ring: '#71717a', // Text colors text: '#FFFFFF', textMuted: '#a1a1aa', // Legacy support for existing components tint: '#FFFFFF', icon: '#a1a1aa', tabIconDefault: '#a1a1aa', tabIconSelected: '#FFFFFF', // Default buttons, links, Send button, selected tabs blue: '#0A84FF', // Success states, FaceTime buttons, completed tasks green: '#30D158', // Delete buttons, error states, critical alerts red: '#FF453A', // VoiceOver highlights, warning states orange: '#FF9F0A', // Notes app accent, Reminders highlights yellow: '#FFD60A', // Pink accent color for various UI elements pink: '#FF375F', // Purple accent for creative apps and features purple: '#BF5AF2', // Teal accent for communication features teal: '#64D2FF', // Indigo accent for system features indigo: '#5E5CE6', // Semantic states success: '#16a34a', successForeground: '#ffffff', warning: '#d97706', warningForeground: '#ffffff', info: '#2563eb', infoForeground: '#ffffff', error: '#dc2626', errorForeground: '#ffffff', }; export const Colors = { light: lightColors, dark: darkColors, }; // Export individual color schemes for easier access export { darkColors, lightColors }; // Utility type for color keys export type ColorKeys = keyof typeof lightColors; // Helper function to get color with opacity (useful for React Native) export const withOpacity = (color: string, opacity: number) => { // Handle rgba colors if (color.startsWith('rgba')) { return color; } // Handle hex colors if (color.startsWith('#')) { const hex = color.replace('#', ''); const r = parseInt(hex.substr(0, 2), 16); const g = parseInt(hex.substr(2, 2), 16); const b = parseInt(hex.substr(4, 2), 16); return `rgba(${r}, ${g}, ${b}, ${opacity})`; } return color; }; ``` **2.** Update the import paths to match your project setup. ## Usage ```tsx import { Colors, withOpacity } from '@/theme/colors'; ``` ```tsx export function ThemedComponent() { const colorScheme = useColorScheme(); const colors = Colors[colorScheme ?? 'light']; return ( <View style={{ backgroundColor: colors.background }}> <Text style={{ color: colors.text }}>Hello World</Text> </View> ); } ``` ## Color Scheme ### Light Mode Colors The light color scheme provides a clean, modern appearance with high contrast for optimal readability. #### Base Colors - `background`: Primary background color (#FFFFFF) - `foreground`: Primary text color (#000000) - `card`: Card background color (#F2F2F7) - `cardForeground`: Card text color (#000000) #### Interactive Colors - `primary`: Primary brand color (#18181b) - `primaryForeground`: Primary text on brand color (#FFFFFF) - `secondary`: Secondary background (#F2F2F7) - `secondaryForeground`: Secondary text color (#18181b) #### System Colors - `blue`: Default buttons, links (#007AFF) - `green`: Success states, completed tasks (#34C759) - `red`: Delete buttons, error states (#FF3B30) - `orange`: Warning states (#FF9500) - `yellow`: Notes app accent (#FFCC00) - `pink`: Pink accent color (#FF2D92) - `purple`: Purple accent (#AF52DE) - `teal`: Communication features (#5AC8FA) - `indigo`: System features (#5856D6) #### Semantic Colors - `success` / `successForeground`: Status indicators for successful operations (#22c55e) - `warning` / `warningForeground`: Status indicators for cautionary states (#f59e0b) - `info` / `infoForeground`: Status indicators for informational messages (#3b82f6) - `error` / `errorForeground`: Status indicators for failed operations (#ef4444) ### Dark Mode Colors The dark color scheme provides a comfortable viewing experience in low-light conditions. #### Base Colors - `background`: Primary background color (#000000) - `foreground`: Primary text color (#FFFFFF) - `card`: Card background color (#1C1C1E) - `cardForeground`: Card text color (#FFFFFF) #### Interactive Colors - `primary`: Primary brand color (#e4e4e7) - `primaryForeground`: Primary text on brand color (#18181b) - `secondary`: Secondary background (#1C1C1E) - `secondaryForeground`: Secondary text color (#FFFFFF) #### System Colors - `blue`: Default buttons, links (#0A84FF) - `green`: Success states, completed tasks (#30D158) - `red`: Delete buttons, error states (#FF453A) - `orange`: Warning states (#FF9F0A) - `yellow`: Notes app accent (#FFD60A) - `pink`: Pink accent color (#FF375F) - `purple`: Purple accent (#BF5AF2) - `teal`: Communication features (#64D2FF) - `indigo`: System features (#5E5CE6) #### Semantic Colors - `success` / `successForeground`: Status indicators for successful operations (#16a34a) - `warning` / `warningForeground`: Status indicators for cautionary states (#d97706) - `info` / `infoForeground`: Status indicators for informational messages (#2563eb) - `error` / `errorForeground`: Status indicators for failed operations (#dc2626) ## API Reference ### Colors Main color export containing light and dark color schemes. ```tsx const Colors = { light: lightColors, dark: darkColors, }; ``` ### withOpacity Utility function to add opacity to colors. #### Parameters | Name | Type | Description | | --------- | -------- | ----------------------------- | | `color` | `string` | The color to add opacity to | | `opacity` | `number` | Opacity value between 0 and 1 | #### Returns | Type | Description | | -------- | ---------------------------------------- | | `string` | Color with opacity applied (rgba format) | #### Example ```tsx import { withOpacity, Colors } from '@/theme/colors'; const semi_transparent_blue = withOpacity(Colors.light.blue, 0.5); // Returns: "rgba(0, 122, 255, 0.5)" ``` ### ColorKeys TypeScript type for all available color keys. ```tsx type ColorKeys = keyof typeof lightColors; ``` ## Usage Examples ### Basic Themed Component ```tsx import { Colors } from '@/theme/colors'; import { useColorScheme } from '@/hooks/useColorScheme'; export function BasicThemedComponent() { const colorScheme = useColorScheme(); const colors = Colors[colorScheme ?? 'light']; return ( <View style={{ backgroundColor: colors.background, padding: 16 }}> <Text style={{ color: colors.text, fontSize: 18 }}> Welcome to the app </Text> <View style={{ backgroundColor: colors.card, padding: 12, borderRadius: 8, marginTop: 16, }} > <Text style={{ color: colors.cardForeground }}> This is a card component </Text> </View> </View> ); } ``` ### Using withOpacity Utility ```tsx import { Colors, withOpacity } from '@/theme/colors'; import { useColorScheme } from '@/hooks/useColorScheme'; export function OverlayComponent() { const colorScheme = useColorScheme(); const colors = Colors[colorScheme ?? 'light']; return ( <View style={{ position: 'relative' }}> <Image source={{ uri: 'https://example.com/image.jpg' }} /> <View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: withOpacity(colors.background, 0.8), justifyContent: 'center', alignItems: 'center', }} > <Text style={{ color: colors.text, fontSize: 24 }}> Overlay Content </Text> </View> </View> ); } ``` ## Best Practices ### Color Consistency Always use the color system instead of hardcoded colors to ensure consistency across your app and proper support for both light and dark modes. ```tsx // ✅ Good - uses theme colors const styles = StyleSheet.create({ container: { backgroundColor: colors.background, }, text: { color: colors.text, }, }); // ❌ Bad - hardcoded colors const styles = StyleSheet.create({ container: { backgroundColor: '#FFFFFF', }, text: { color: '#000000', }, }); ``` ### Semantic Usage Use semantic colors for status indicators and system feedback to maintain consistency with platform conventions. ```tsx // ✅ Good - semantic colors, resolved for the active theme <Text style={{ color: colors.success }}> Operation completed successfully </Text> // ❌ Bad - arbitrary green, and ignores the active theme <Text style={{ color: '#00FF00' }}> Operation completed successfully </Text> ``` `success`, `warning`, `info`, and `error` (plus their `*Foreground` counterparts) live directly on `Colors.light`/`Colors.dark` alongside every other token, so they also work with the `useColor` hook: ```tsx import { useColor } from '@/hooks/useColor'; const successColor = useColor('success'); ``` ### Performance Considerations The color objects are static and can be safely memoized or cached. Consider using React.memo for components that only change based on color scheme. ```tsx const ThemedComponent = React.memo(({ colorScheme }) => { const colors = Colors[colorScheme]; // Component implementation }); ``` ## Accessibility The color system is designed with accessibility in mind, providing sufficient contrast ratios between foreground and background colors. The semantic colors also follow platform conventions for better user experience. When creating custom color combinations, ensure they maintain proper contrast ratios for accessibility compliance (minimum 4.5:1 for normal text, 3:1 for large text). ## Platform Compatibility The color system follows iOS design guidelines and adapts automatically to system-level appearance changes. Colors are optimized for both light and dark modes across all supported platforms. <!-- ---------------------------------------------------------------------- --> # Globals > Global design constants for consistent spacing, sizing, and styling across your React Native app components. **BNA UI** — a React Native / Expo component library. These components render through `react-native`, not the DOM: there are no HTML elements, no Tailwind classes and no Radix primitives. Source is copied into your project and imported through `@/components/ui/*`, `@/components/charts/*`, `@/hooks/*` and `@/theme/*`. Colours come from the `useColor` hook rather than hardcoded hex; sizing tokens (`HEIGHT`, `FONT_SIZE`, `BORDER_RADIUS`, `CORNERS`) come from `@/theme/globals`. - Docs: https://ui.ahmedbna.com/docs/theme/globals - Markdown: https://ui.ahmedbna.com/docs/theme/globals.md - Structured JSON (props, usage, source, examples): https://ui.ahmedbna.com/r/ai/globals.json - Install payload (source plus every file it imports): https://ui.ahmedbna.com/r/globals.json - Install: `npx bna-ui add globals` --- ## Installation ### CLI ```bash npx bna-ui add globals ``` ### Manual **1.** Copy and paste the following code into your project. ```ts // theme/globals.ts export const HEIGHT = 48; export const FONT_SIZE = 17; export const BORDER_RADIUS = 26; export const CORNERS = 999; export const SPACING = { xs: 4, sm: 8, md: 16, lg: 24, xl: 32, }; ``` **2.** Update the import paths to match your project setup. ## Usage ```tsx import { HEIGHT, FONT_SIZE, BORDER_RADIUS, CORNERS } from '@/theme/globals'; ``` ```tsx export function StyledButton({ title, onPress }) { return ( <TouchableOpacity style={{ height: HEIGHT, backgroundColor: '#007AFF', borderRadius: BORDER_RADIUS, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 16, }} onPress={onPress} > <Text style={{ color: 'white', fontSize: FONT_SIZE, fontWeight: '600', }} > {title} </Text> </TouchableOpacity> ); } ``` ## Constants ### HEIGHT Standard height for interactive elements like buttons, input fields, and list items. | Value | Description | | ----- | ------------------------------------------------------ | | `48` | Standard height in pixels for consistent touch targets | **Usage:** ```tsx const buttonStyle = { height: HEIGHT, minHeight: HEIGHT, }; ``` ### FONT\_SIZE Standard font size for body text and interactive elements. | Value | Description | | ----- | -------------------------------------------------------- | | `17` | Base font size in pixels following iOS design guidelines | **Usage:** ```tsx const textStyle = { fontSize: FONT_SIZE, lineHeight: FONT_SIZE * 1.4, // 23.8px line height }; ``` ### BORDER\_RADIUS Standard border radius for rounded elements like buttons and cards. | Value | Description | | ----- | ------------------------------------------------------ | | `26` | Border radius in pixels for moderately rounded corners | **Usage:** ```tsx const cardStyle = { borderRadius: BORDER_RADIUS, backgroundColor: '#F2F2F7', }; ``` ### CORNERS Maximum border radius for fully rounded elements like circular buttons or pills. | Value | Description | | ----- | ---------------------------------------------------- | | `999` | Large border radius value for fully rounded elements | **Usage:** ```tsx const pillStyle = { borderRadius: CORNERS, paddingHorizontal: 16, paddingVertical: 8, }; ``` ### SPACING A five-step spacing scale for margins, padding, and gaps. | Key | Value | Description | | ---- | ----- | --------------------------------------- | | `xs` | `4` | Tight spacing, icon gaps | | `sm` | `8` | Compact spacing between related content | | `md` | `16` | Default spacing between elements | | `lg` | `24` | Section-level spacing | | `xl` | `32` | Large layout spacing | **Usage:** ```tsx const cardStyle = { padding: SPACING.md, gap: SPACING.sm, marginBottom: SPACING.lg, }; ``` ## Usage Examples ### Standard Button Component ```tsx import { HEIGHT, FONT_SIZE, BORDER_RADIUS } from '@/theme/globals'; import { Colors } from '@/theme/colors'; export function Button({ title, variant = 'primary', onPress, disabled }) { const colors = Colors.light; // or use useColorScheme hook const getButtonStyle = () => { const baseStyle = { height: HEIGHT, borderRadius: BORDER_RADIUS, justifyContent: 'center', alignItems: 'center', paddingHorizontal: 24, }; switch (variant) { case 'primary': return { ...baseStyle, backgroundColor: disabled ? colors.muted : colors.primary, }; case 'secondary': return { ...baseStyle, backgroundColor: colors.secondary, borderWidth: 1, borderColor: colors.border, }; default: return baseStyle; } }; const getTextStyle = () => ({ fontSize: FONT_SIZE, fontWeight: '600', color: variant === 'primary' ? colors.primaryForeground : colors.text, }); return ( <TouchableOpacity style={getButtonStyle()} onPress={onPress} disabled={disabled} > <Text style={getTextStyle()}>{title}</Text> </TouchableOpacity> ); } ``` ### Input Field Component ```tsx import { HEIGHT, FONT_SIZE, BORDER_RADIUS } from '@/theme/globals'; import { Colors } from '@/theme/colors'; export function TextInput({ placeholder, value, onChangeText, ...props }) { const colors = Colors.light; return ( <View style={{ height: HEIGHT, borderRadius: BORDER_RADIUS, backgroundColor: colors.input, borderWidth: 1, borderColor: colors.border, paddingHorizontal: 16, justifyContent: 'center', }} > <TextInput style={{ fontSize: FONT_SIZE, color: colors.text, flex: 1, }} placeholder={placeholder} placeholderTextColor={colors.mutedForeground} value={value} onChangeText={onChangeText} {...props} /> </View> ); } ``` ## Best Practices ### Consistency Always use the global constants instead of hardcoded values to ensure consistency across your app. ```tsx // ✅ Good - uses global constants const styles = StyleSheet.create({ button: { height: HEIGHT, borderRadius: BORDER_RADIUS, }, text: { fontSize: FONT_SIZE, }, }); // ❌ Bad - hardcoded values const styles = StyleSheet.create({ button: { height: 48, borderRadius: 26, }, text: { fontSize: 17, }, }); ``` ### Variations Use the constants as a base and create variations when needed. ```tsx // Standard size const normalButton = { height: HEIGHT, fontSize: FONT_SIZE, }; // Large size const largeButton = { height: HEIGHT * 1.25, // 60px fontSize: FONT_SIZE + 2, // 19px }; // Small size const smallButton = { height: HEIGHT * 0.75, // 36px fontSize: FONT_SIZE - 2, // 15px }; ``` ### Responsive Design Consider using these constants as a base for responsive design calculations. ```tsx import { Dimensions } from 'react-native'; import { HEIGHT, FONT_SIZE } from '@/theme/globals'; const { width } = Dimensions.get('window'); const isTablet = width > 768; const responsiveStyles = { button: { height: isTablet ? HEIGHT * 1.2 : HEIGHT, fontSize: isTablet ? FONT_SIZE + 1 : FONT_SIZE, }, }; ``` ## Design System Integration These constants form the foundation of your design system and should be used alongside your color system for consistent theming. ```tsx import { HEIGHT, FONT_SIZE, BORDER_RADIUS, CORNERS, SPACING, } from '@/theme/globals'; import { Colors } from '@/theme/colors'; export const designSystem = { spacing: SPACING, sizing: { height: HEIGHT, borderRadius: BORDER_RADIUS, corners: CORNERS, }, typography: { fontSize: FONT_SIZE, lineHeight: FONT_SIZE * 1.4, }, colors: Colors, }; ``` ## Platform Considerations The global constants follow iOS design guidelines and provide optimal touch targets and readability across different screen sizes and densities. The 48px height ensures comfortable interaction on both phones and tablets. ## Accessibility The HEIGHT constant (48px) meets accessibility guidelines for minimum touch target size, ensuring comfortable interaction for users with motor impairments. The FONT\_SIZE (17px) provides good readability while maintaining the platform's design language.