# 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 (
    <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',
      }}
      aspectRatio={1}
    />
  );
}
```

## 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<ExpoImageProps, 'style'> {
  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<ExpoImage, ImageProps>(
  (
    {
      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<ImageProps['onLoadStart']> = (
      ...args
    ) => {
      setIsLoading(true);
      setHasError(false);
      onLoadStart?.(...args);
    };

    const handleLoadEnd: NonNullable<ImageProps['onLoadEnd']> = (...args) => {
      setIsLoading(false);
      onLoadEnd?.(...args);
    };

    const handleError: NonNullable<ImageProps['onError']> = (...args) => {
      setIsLoading(false);
      setHasError(true);
      onError?.(...args);
    };

    return (
      <View style={containerStyles}>
        <ExpoImage
          ref={ref}
          source={source}
          style={imageStyles}
          contentFit={contentFit}
          transition={transition}
          onLoadStart={handleLoadStart}
          onLoadEnd={handleLoadEnd}
          onError={handleError}
          {...props}
        />

        {/* Loading indicator */}
        {isLoading && showLoadingIndicator && (
          <View style={styles.overlay}>
            <ActivityIndicator
              size={loadingIndicatorSize}
              color={loadingIndicatorColor || primaryColor}
            />
          </View>
        )}

        {/* Error fallback */}
        {hasError && showErrorFallback && (
          <View style={[styles.overlay, styles.errorContainer]}>
            <Text
              variant='caption'
              style={[styles.errorText, { color: textColor }]}
              numberOfLines={2}
            >
              {errorFallbackText}
            </Text>
          </View>
        )}
      </View>
    );
  }
);

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
<Image
  source={{ uri: 'https://picsum.photos/400/300' }}
  width={400}
  height={300}
/>
```

## 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 (
    <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',
      }}
      aspectRatio={1}
    />
  );
}
```

#### 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 (
    <View style={{ gap: 16 }}>
      <View style={{ alignItems: 'center', gap: 8 }}>
        <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',
          }}
          variant='rounded'
          width={200}
          aspectRatio={1}
        />
        <Text variant='caption'>Rounded</Text>
      </View>

      <View style={{ alignItems: 'center', gap: 8 }}>
        <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',
          }}
          width={200}
          height={200}
          variant='circle'
        />
        <Text variant='caption'>Circle</Text>
      </View>

      <View style={{ alignItems: 'center', gap: 8 }}>
        <Image
          source={{
            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',
          }}
          width={200}
          height={200}
          variant='default'
        />
        <Text variant='caption'>Default</Text>
      </View>
    </View>
  );
}
```

#### 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 (
    <View style={{ gap: 16 }}>
      <View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}>
        <Image
          source={{ uri: 'https://picsum.photos/150/150' }}
          width={50}
          height={50}
        />
        <Image
          source={{ uri: 'https://picsum.photos/151/151' }}
          width={75}
          height={75}
        />
        <Image
          source={{ uri: 'https://picsum.photos/152/152' }}
          width={100}
          height={100}
        />
        <Image
          source={{ uri: 'https://picsum.photos/153/153' }}
          width={125}
          height={125}
        />
      </View>

      <View style={{ gap: 8 }}>
        <Text variant='caption'>Aspect Ratio Examples</Text>
        <View style={{ flexDirection: 'row', gap: 12 }}>
          <Image
            source={{ uri: 'https://picsum.photos/400/300' }}
            width={120}
            aspectRatio={4 / 3}
          />
          <Image
            source={{ uri: 'https://picsum.photos/300/400' }}
            width={120}
            aspectRatio={3 / 4}
          />
          <Image
            source={{ uri: 'https://picsum.photos/500/300' }}
            width={120}
            aspectRatio={16 / 9}
          />
        </View>
      </View>
    </View>
  );
}
```

#### 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 (
    <View style={{ gap: 16 }}>
      <View style={{ gap: 8 }}>
        <Text variant='caption'>Small Loading Indicator</Text>
        <Image
          source={{ uri: 'https://picsum.photos/400/300?random=1' }}
          width={200}
          height={150}
          loadingIndicatorSize='small'
        />
      </View>

      <View style={{ gap: 8 }}>
        <Text variant='caption'>Large Loading Indicator</Text>
        <Image
          source={{ uri: 'https://picsum.photos/400/300?random=2' }}
          width={200}
          height={150}
          loadingIndicatorSize='large'
        />
      </View>

      <View style={{ gap: 8 }}>
        <Text variant='caption'>Custom Loading Color</Text>
        <Image
          source={{ uri: 'https://picsum.photos/400/300?random=3' }}
          width={200}
          height={150}
          loadingIndicatorColor='#FF6B6B'
        />
      </View>

      <View style={{ gap: 8 }}>
        <Text variant='caption'>No Loading Indicator</Text>
        <Image
          source={{ uri: 'https://picsum.photos/400/300?random=4' }}
          width={200}
          height={150}
          showLoadingIndicator={false}
        />
      </View>
    </View>
  );
}
```

#### 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 (
    <View style={{ gap: 16 }}>
      <View style={{ gap: 8 }}>
        <Text variant='caption'>Default Error Fallback</Text>
        <Image
          source={{ uri: 'https://invalid-url-that-will-fail.com/image.jpg' }}
          width={200}
          height={150}
        />
      </View>

      <View style={{ gap: 8 }}>
        <Text variant='caption'>Custom Error Message</Text>
        <Image
          source={{ uri: 'https://another-invalid-url.com/image.jpg' }}
          width={200}
          height={150}
          errorFallbackText='Oops! Image not found'
        />
      </View>

      <View style={{ gap: 8 }}>
        <Text variant='caption'>No Error Fallback</Text>
        <Image
          source={{ uri: 'https://yet-another-invalid-url.com/image.jpg' }}
          width={200}
          height={150}
          showErrorFallback={false}
        />
      </View>

      <View style={{ gap: 8 }}>
        <Text variant='caption'>Circle Variant with Error</Text>
        <Image
          source={{ uri: 'https://broken-image-url.com/avatar.jpg' }}
          width={100}
          height={100}
          variant='circle'
          errorFallbackText='No Avatar'
        />
      </View>
    </View>
  );
}
```

#### 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 (
    <View style={{ gap: 16 }}>
      <Text variant='caption'>Grid Gallery</Text>
      <View
        style={{
          flexDirection: 'row',
          flexWrap: 'wrap',
          gap: 8,
          justifyContent: 'space-between',
        }}
      >
        {images.slice(0, 4).map((uri, index) => (
          <Image
            key={index}
            source={{ uri }}
            width={90}
            height={90}
            style={{ borderRadius: 8 }}
          />
        ))}
      </View>

      <Text variant='caption'>Horizontal Scroll Gallery</Text>
      <ScrollView
        horizontal
        showsHorizontalScrollIndicator={false}
        contentContainerStyle={{ gap: 12 }}
      >
        {images.map((uri, index) => (
          <Image
            key={index}
            source={{ uri }}
            width={150}
            height={100}
            style={{ borderRadius: 8 }}
          />
        ))}
      </ScrollView>

      <Text variant='caption'>Featured Image</Text>
      <Image
        source={{ uri: 'https://picsum.photos/800/400?random=99' }}
        width='100%'
        aspectRatio={2}
        style={{ borderRadius: 12 }}
      />
    </View>
  );
}
```

#### 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 (
    <View style={{ gap: 16 }}>
      <View style={{ gap: 8 }}>
        <Text variant='caption'>Full Width (Container Responsive)</Text>

        <Image
          source={{ uri: 'https://picsum.photos/800/300?random=20' }}
          aspectRatio={8 / 3}
        />
      </View>

      <View style={{ gap: 8 }}>
        <Text variant='caption'>Percentage Width</Text>
        <View style={{ flexDirection: 'row', gap: 8 }}>
          <Image
            source={{ uri: 'https://picsum.photos/400/300?random=21' }}
            width='48%'
            aspectRatio={4 / 3}
          />
          <Image
            source={{ uri: 'https://picsum.photos/400/300?random=22' }}
            width='48%'
            aspectRatio={4 / 3}
          />
        </View>
      </View>

      <View style={{ gap: 8 }}>
        <Text variant='caption'>Flex Layout</Text>
        <View style={{ flexDirection: 'row', gap: 8 }}>
          <View style={{ flex: 2 }}>
            <Image
              source={{ uri: 'https://picsum.photos/600/400?random=23' }}
              aspectRatio={3 / 2}
            />
          </View>
          <View style={{ flex: 1 }}>
            <Image
              source={{ uri: 'https://picsum.photos/300/400?random=24' }}
              aspectRatio={3 / 4}
            />
          </View>
        </View>
      </View>
    </View>
  );
}
```

#### 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 (
    <View style={{ gap: 16 }}>
      <View style={{ gap: 8 }}>
        <Text variant='caption'>Cover (Default)</Text>

        <Image
          source={{ uri: imageUri }}
          width={150}
          height={100}
          contentFit='cover'
        />
      </View>

      <View style={{ gap: 8 }}>
        <Text variant='caption'>Contain</Text>

        <Image
          source={{ uri: imageUri }}
          width={150}
          height={100}
          contentFit='contain'
        />
      </View>

      <View style={{ gap: 8 }}>
        <Text variant='caption'>Fill</Text>

        <Image
          source={{ uri: imageUri }}
          width={150}
          height={100}
          contentFit='fill'
        />
      </View>

      <View style={{ gap: 8 }}>
        <Text variant='caption'>Scale Down</Text>

        <Image
          source={{ uri: 'https://picsum.photos/100/80?random=31' }}
          width={150}
          height={100}
          contentFit='scale-down'
        />
      </View>

      <View style={{ gap: 8 }}>
        <Text variant='caption'>None</Text>

        <Image
          source={{ uri: 'https://picsum.photos/100/60?random=32' }}
          width={150}
          height={100}
          contentFit='none'
        />
      </View>
    </View>
  );
}
```

## 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
