
Universal Image
component
import { Image } from '@green-stack/components/Image'
Platform Optimized Images
Some primitives like the Image
component have optimized versions for each environment:
next/image
for webexpo-image
for mobile
To automatically use the right one per render context, we’ve provided our own universal Image
component:
<Image
src={require('@app/assets/green-stack-logo.png')} // or a URL
className="rounded-full"
width={60}
height={60}
unoptimized
/>
Which you might wish to wrap with Nativewind to provide class names to:
import { Image as UniversalImage } from '@green-stack/components/Image'
// ☝️ Import the universal Image component
import { styled } from 'nativewind'
// ⬇⬇⬇
export const Image = styled(UniversalImage, '')
// ☝️ Adds the ability to assign tailwind classes
Image Props
Prop | Type | Default | Description |
---|---|---|---|
src | string | StaticImageData | Required | Image source - can be URL or imported image |
alt | string | 'Alt description missing in image' | Alt text for accessibility and SEO |
width | number | string | Required* | Width in pixels or percentage |
height | number | string | Required* | Height in pixels or percentage |
className | string | - | Tailwind classes for styling |
style | StyleProp<ViewStyle> | {} | Additional styles |
priority | 'high' | 'normal' | 'normal' | Loading priority - ‘high’ for LCP elements |
onError | (error: any) => void | - | Called on image loading error |
onLoadEnd | () => void | - | Called when image load completes |
fill | boolean | - | Fill parent container (requires relative positioning) |
contentFit | 'cover' | 'contain' | 'fill' | 'none' | 'scale-down' | 'cover' | How image fits container |
cachePolicy | 'none' | 'disk' | 'memory' | 'memory-disk' | 'disk' | Image caching strategy |
blurRadius | number | 0 | Blur effect radius in points |
quality | number | 75 | Image quality (1-100) |
sizes | string | - | Responsive image sizes hint |
unoptimized | boolean | false | Skip image optimization |
*Required unless using fill
or static import
TypeScript Definition
You can find the TypeScript definition for our Universal Image
component in Image.types.ts
:
type UniversalImageProps = {
// -- Universal props --
/**
* Universal, will affect both Expo & Next.js - Must be one of the following:
* - A path string like `'/assets/logo.png'`. This can be either an absolute external URL, or an internal path depending on the loader prop.
* - A statically imported image file, like `import logo from './logo.png'` or `require('./logo.png')`.
*
* When using an external URL, you must add it to `remotePatterns` in `next.config.js`.
* @platform web, android, ios @framework expo, next.js */
src: string | StaticImport
width?: number | `${number}` | `${number}%`
height?: number | `${number}` | `${number}%`
/** Universal, will affect both Expo & Next.js
* - Remember that the required width and height props can interact with your styling. If you use styling to modify an image's width, you should also style its height to auto to preserve its intrinsic aspect ratio, or your image will be distorted. */
className?: string
/** Universal, will affect both Expo & Next.js
* - Remember that the required width and height props can interact with your styling. If you use styling to modify an image's width, you should also style its height to auto to preserve its intrinsic aspect ratio, or your image will be distorted. */
style?: StyleProp<ImageStyle> | ExpoImageProps['style']
alt?: string
priority?: "low" | "normal" | "high" | null
onError?: ((event: ImageErrorEventData) => void)
onLoadEnd?: (() => void)
// -- '@next/image' specific props --
/** Custom function used to resolve image URLs. A loader is a function returning a URL string for the image, given the following parameters: `src`, `width`, `quality` (`number` from 0 - 1) Alternatively, you can use the [loaderFile](https://nextjs.org/docs/pages/api-reference/components/image#loaderfile) configuration in next.config.js to configure every instance of next/image in your application, without passing a prop. */
loader?: ImageLoader
fill?: boolean
sizes?: string
quality?: number | `${number}`
nextPlaceholder?: PlaceholderValue | 'blur' | 'empty' | `data:image/${string}`
loading?: 'lazy' | 'eager'
blurDataURL?: string
unoptimized?: boolean
// -- 'expo-image' specific props --
accessibilityLabel?: string
accessible?: boolean
allowDownscaling?: boolean
autoplay?: boolean
blurRadius?: number
cachePolicy?: 'none' | 'disk' | 'memory' | 'memory-disk'
contentFit?: ImageContentFit | 'cover' | 'contain' | 'fill' | 'none' | 'scale-down'
contentPosition?: ImageContentPosition | 'top' | 'bottom' | 'left' | 'right' | 'center' | 'top left' | 'top right' | ...
enableLiveTextInteraction?: ExpoImageProps['enableLiveTextInteraction']
focusable?: boolean
expoPlaceholder?: ExpoImageProps['expoPlaceholder'] | string | StaticImport
onLoadStart?: (() => void)
onProgress?: ((event: ImageProgressEventData) => void)
placeholderContentFit?: ImageContentFit | 'cover' | 'contain' | 'fill' | 'none' | 'scale-down'
recyclingKey?: string | null
responsivePolicy?: 'static' | 'initial' | 'live'
}
Zod Schema
import { z } from 'zod'
const UniversalImageSchema = z.object({
// Universal props
src: z.union([z.string(), z.any()]), // StaticImageData type
alt: z.string().optional(),
width: z.union([z.number(), z.string()]).optional(),
height: z.union([z.number(), z.string()]).optional(),
className: z.string().optional(),
style: z.any().optional(),
priority: z.enum(['high', 'normal']).optional(),
onError: z.function().optional(),
onLoadEnd: z.function().optional(),
// Next.js specific
loader: z.function().optional(),
fill: z.boolean().optional(),
sizes: z.string().optional(),
quality: z.number().min(1).max(100).optional(),
nextPlaceholder: z.enum(['blur', 'empty']).optional(),
loading: z.enum(['lazy', 'eager']).optional(),
blurDataURL: z.string().optional(),
unoptimized: z.boolean().optional(),
// Expo specific
accessibilityLabel: z.string().optional(),
accessible: z.boolean().optional(),
allowDownscaling: z.boolean().optional(),
autoplay: z.boolean().optional(),
blurRadius: z.number().optional(),
cachePolicy: z.enum(['none', 'disk', 'memory', 'memory-disk']).optional(),
contentFit: z.enum(['cover', 'contain', 'fill', 'none', 'scale-down']).optional(),
contentPosition: z.string().optional(),
enableLiveTextInteraction: z.boolean().optional(),
focusable: z.boolean().optional(),
expoPlaceholder: z.any().optional(),
onLoadStart: z.function().optional(),
onProgress: z.function().optional(),
placeholderContentFit: z.enum(['cover', 'contain', 'fill', 'none', 'scale-down']).optional(),
recyclingKey: z.string().optional(),
responsivePolicy: z.enum(['static', 'initial', 'live']).optional(),
})
React Portability Patterns
Both Next.js and Expo have their own optimized Image
components. This is why there are also versions specifically for each of those environments:
- Image.expo.tsx
- Image.next.tsx
- Image.tsx
- Image.types.ts
Image.next.tsx
usesnext/image
for webImage.expo.tsx
usesexpo-image
for mobileImage.types.ts
ensures there is a shared type for both implementations
Finally, Image.tsx
will retrieve whichever implementation was provided as contextImage
to the <UniversalAppProviders>
component, which is further passed to <CoreContext.Provider/>
:
import { Image as ExpoImage } from '@green-stack/components/Image.expo'
// ... Later ...
<UniversalAppProviders
contextImage={ExpoImage}
>
...
</UniversalAppProviders>
import { Image as NextImage } from '@green-stack/components/Image.next'
// ... Later ...
<UniversalAppProviders
contextImage={NextImage}
>
...
</UniversalAppProviders>
Why this pattern?
The ‘React Portability Patterns’ used here are designed to ensure that you can easily reuse optimized versions of components across different flavours of writing React.
On the one hand, that means it’s already set up to work with both Expo and Next.js in an optimal way.
But, you can actually add your own implementations for other environments, without having to refactor the code that uses the Image
component.
Supporting more environments
Just add your own Image.<environment>.tsx
file that respects the shared types, and then pass it to the <UniversalAppProviders>
component as contextImage
.