suiss/ui
  • Docs
  • Components
  • Patterns
  • Interactions
  • Charts
  • Blocks
GitHubGet started
DocsComponentsPatternsInteractionsChartsBlocks
Interactions15
  • Overview
  • Card Stack
  • Image Viewer
  • Island
  • OTP Autofill
  • Preview Menu
  • Pull to Refresh
  • Reorder List
  • Search Reveal
  • Section Index
  • Selection List
  • Stepper
  • Swipe Actions
  • Tip
  • Top Banner
  • Undo Bar

suiss/ui · Interaction

Image Viewer

A full-screen photo viewer with swipe paging, pinch and double-tap zoom, and swipe down to close.

Image Viewer is an open-source interaction from suiss/ui for React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

iOS
Image Viewer (open by default) on iOS in light modeImage Viewer (open by default) on iOS in dark mode
import { Image, View } from "react-native"
import { ImageViewer, ImageViewerContent, type ImageViewerImage, ImageViewerTrigger } from "@/components/interactions/image-viewer"
import { Text } from "@/components/ui/text"

const images: ImageViewerImage[] = [
  {
    source: {
      uri: "https://images.unsplash.com/photo-1588345921523-c2dcdb7f1dcd?w=1200&q=80",
    },
    accessibilityLabel: "Mountain lake at sunrise",
  },
  {
    source: {
      uri: "https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=1200&q=80",
    },
    accessibilityLabel: "Valley with a river",
  },
  {
    source: {
      uri: "https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=1200&q=80",
    },
    accessibilityLabel: "Lake between mountains",
  },
  {
    source: {
      uri: "https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=1200&q=80",
    },
    accessibilityLabel: "Foggy hills",
  },
  {
    source: {
      uri: "https://images.unsplash.com/photo-1441974231531-c6227db76b6e?w=1200&q=80",
    },
    accessibilityLabel: "Forest path",
  },
]

function Gallery({
  defaultOpen,
  defaultIndex,
}: {
  defaultOpen?: boolean
  defaultIndex?: number
}) {
  return (
    <ImageViewer
      images={images}
      defaultOpen={defaultOpen}
      defaultIndex={defaultIndex}
    >
      <View className="gap-3">
        <View className="flex-row flex-wrap gap-2">
          {images.map((image, index) => (
            <ImageViewerTrigger
              key={image.accessibilityLabel}
              index={index}
              accessibilityLabel={image.accessibilityLabel}
              className="aspect-square w-[31%] overflow-hidden rounded-xl bg-muted"
            >
              <Image source={image.source} className="h-full w-full" />
            </ImageViewerTrigger>
          ))}
        </View>
        <Text variant="muted">
          Tap a photo. Pinch or double-tap to zoom, swipe down to close.
        </Text>
      </View>
      <ImageViewerContent />
    </ImageViewer>
  )
}

export function ImageViewerOpenByDefaultExample() {
  return (
    <Gallery defaultOpen defaultIndex={1} />
  )
}
import EsuissUI
import SwiftUI
import UIKit

struct ImageViewerOpenByDefaultExample: View {
  var body: some View {
    ImageViewerGallery(open: true, index: 1)
  }
}

struct ImageViewerPhoto {
  let url: URL
  let label: String
}

let imageViewerPhotos = [
  ImageViewerPhoto(url: URL(string: "https://images.unsplash.com/photo-1588345921523-c2dcdb7f1dcd?w=1200&q=80")!, label: "Mountain lake at sunrise"),
  ImageViewerPhoto(url: URL(string: "https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=1200&q=80")!, label: "Valley with a river"),
  ImageViewerPhoto(url: URL(string: "https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=1200&q=80")!, label: "Lake between mountains"),
  ImageViewerPhoto(url: URL(string: "https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=1200&q=80")!, label: "Foggy hills"),
  ImageViewerPhoto(url: URL(string: "https://images.unsplash.com/photo-1441974231531-c6227db76b6e?w=1200&q=80")!, label: "Forest path"),
]

struct ImageViewerGallery: View {
  @State private var open: Bool
  @State private var index: Int
  @State private var loaded: [Int: UIImage] = [:]

  init(open: Bool = false, index: Int = 0) {
    _open = State(initialValue: open)
    _index = State(initialValue: index)
  }

  var images: [Image] {
    imageViewerPhotos.indices.map { page in
      let photo = imageViewerPhotos[page]
      if let cgImage = loaded[page]?.cgImage {
        return Image(cgImage, scale: 1, orientation: .up, label: Text(verbatim: photo.label))
      }
      return Image(uiImage: UIImage())
    }
  }

  var body: some View {
    VStack(alignment: .leading, spacing: 12) {
      GeometryReader { proxy in
        let side = ((proxy.size.width - 16) / 3).rounded(.down)
        FlowRow(spacing: 8) {
          ForEach(imageViewerPhotos.indices, id: \.self) { page in
            Button {
              index = page
              open = true
            } label: {
              thumbnail(page)
                .frame(width: side, height: side)
                .background(EsuissColor.muted)
                .clipShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
            }
            .buttonStyle(.plain)
            .accessibilityLabel(Text(verbatim: imageViewerPhotos[page].label))
          }
        }
      }
      .aspectRatio(3 / 2, contentMode: .fit)
      Text("Tap a photo. Pinch or double-tap to zoom, swipe down to close.")
        .textVariant(.muted)
    }
    .imageViewer(isPresented: $open, images: images, index: $index)
    .task {
      await load()
    }
  }

  @ViewBuilder
  func thumbnail(_ page: Int) -> some View {
    if let image = loaded[page] {
      Image(uiImage: image)
        .resizable()
        .scaledToFill()
    } else {
      EsuissColor.muted
    }
  }

  func load() async {
    await withTaskGroup(of: (Int, UIImage?).self) { group in
      for (page, photo) in imageViewerPhotos.enumerated() {
        group.addTask {
          guard let (data, _) = try? await URLSession.shared.data(from: photo.url) else { return (page, nil) }
          return (page, UIImage(data: data))
        }
      }
      for await (page, image) in group {
        if let image { loaded[page] = image }
      }
    }
  }
}

let group = StoryGroup(.interactions, "Image Viewer", stories: [
  Story("Default") {
    ImageViewerGallery()
  },
  Story("OpenByDefault") {
    ImageViewerGallery(open: true, index: 1)
  },
]
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant
import com.esuiss.ui.interactions.ImageViewer
import com.esuiss.ui.interactions.ImageViewerImage
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Radius

@Composable
fun ImageViewerOpenByDefaultExample() {
    ImageViewerGallery(defaultOpen = true, defaultIndex = 1)
}

class ImageViewerGradient(private val from: Color, private val to: Color, private val size: Size) : Painter() {
    override val intrinsicSize: Size
        get() = size

    override fun DrawScope.onDraw() {
        drawRect(Brush.linearGradient(listOf(from, to), start = Offset.Zero, end = Offset(this.size.width, this.size.height)))
    }
}

val imageViewerLabels = listOf(
    "Mountain lake at sunrise",
    "Valley with a river",
    "Lake between mountains",
    "Foggy hills",
    "Forest path",
)

@Composable
fun ImageViewerGallery(defaultOpen: Boolean, defaultIndex: Int) {
    val colors = EsuissTheme.colors
    var open by rememberSaveable { mutableStateOf(defaultOpen) }
    var index by rememberSaveable { mutableIntStateOf(defaultIndex) }
    val tones = listOf(
        Palette.orange500 to Palette.pink500,
        Palette.teal500 to Palette.blue500,
        Palette.indigo500 to Palette.cyan500,
        Palette.zinc500 to Palette.teal400,
        Palette.green500 to Palette.amber500,
    )
    val images = remember(tones) {
        imageViewerLabels.mapIndexed { position, label ->
            val (from, to) = tones[position]
            ImageViewerImage(ImageViewerGradient(from, to, Size(1200f, 800f)), contentDescription = label)
        }
    }
    val shape = RoundedCornerShape(Radius.xl)
    Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
        FlowRow(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.spacedBy(8.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp),
        ) {
            images.forEachIndexed { position, image ->
                Image(
                    painter = image.painter,
                    contentDescription = null,
                    contentScale = ContentScale.Crop,
                    modifier = Modifier
                        .fillMaxWidth(0.31f)
                        .aspectRatio(1f)
                        .clip(shape)
                        .background(colors.muted, shape)
                        .semantics { contentDescription = imageViewerLabels[position] }
                        .clickable(role = Role.Button) {
                            index = position
                            open = true
                        },
                )
            }
        }
        Text("Tap a photo. Pinch or double-tap to zoom, swipe down to close.", variant = TextVariant.Muted)
    }
    ImageViewer(
        open = open,
        onOpenChange = { open = it },
        images = images,
        index = index,
        onIndexChange = { index = it },
    )
}

ImageViewer opens a set of images full screen on a black background, starting at the pressed thumbnail. A counter such as 2 of 5 and a close button sit at the top, below the safe area.

Swipe sideways to page between images, with resistance at the first and last image. Pinch to zoom between 1x and 4x around the fingers, double-tap to zoom to 2.5x at the tapped point or back to 1x, and drag to pan while zoomed. Swiping up or down far enough closes the viewer, and a single tap toggles the controls.

Installation

$ pnpm dlx shadcn@latest add @esuiss-rn/image-viewer

See Installation for the components.json setup. Or use the suiss/ui CLI:

$ pnpm dlx @esuiss/ui@latest add image-viewer
$ pnpm dlx @esuiss/ui@latest add image-viewer --platform swift
$ pnpm dlx @esuiss/ui@latest add image-viewer --platform kotlin

The CLI also adds what it builds on: Icon, Text. On React Native it also adds the animated view components/ui/animated-view.tsx.

Manual

  1. Install the dependencies:
npx expo install cn phosphor-react-native react-native-reanimated react-native-safe-area-context react-native-screens react-native-svg react-native-worklets
  1. Add the pieces it builds on: Icon, Text, and the primitives components/primitives/image-viewer.tsx, components/primitives/use-render.tsx, and the animated view components/ui/animated-view.tsx from the next step.

  2. Copy the animated view into components/ui/animated-view.tsx:

uniwind/components/ui/animated-view.tsx
import { Pressable } from "react-native"
import Animated from "react-native-reanimated"

const AnimatedPressable = Animated.createAnimatedComponent(Pressable)

type AnimatedViewProps =
  | (React.ComponentProps<typeof Animated.View> &
      React.RefAttributes<React.ComponentRef<typeof Animated.View>> & {
        as?: "View"
      })
  | (React.ComponentProps<typeof AnimatedPressable> &
      React.RefAttributes<React.ComponentRef<typeof AnimatedPressable>> & {
        as: "Pressable"
      })

function AnimatedView(props: AnimatedViewProps) {
  1. Copy the source into components/interactions/image-viewer.tsx:
uniwind/components/interactions/image-viewer.tsx
import { cn } from "cn"
import { XIcon } from "phosphor-react-native"
import * as React from "react"
import { Platform, View } from "react-native"
import { FadeIn, FadeOut, ReduceMotion } from "react-native-reanimated"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens"
import * as ImageViewerPrimitive from "@/components/primitives/image-viewer"
import { resolveClassName } from "@/components/primitives/use-render"
import { AnimatedView } from "@/components/ui/animated-view"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

type ImageViewerImage = ImageViewerPrimitive.ImageViewerImage

const FullWindowOverlay =
  1. Update the import paths to match your project.
  1. Install the dependencies:
npx expo install cn phosphor-react-native react-native-reanimated react-native-safe-area-context react-native-screens react-native-svg react-native-worklets
  1. Add the pieces it builds on: Icon, Text, and the primitives components/primitives/image-viewer.tsx, components/primitives/use-render.tsx, and the animated view components/ui/animated-view.tsx from the next step.

  2. Copy the animated view into components/ui/animated-view.tsx:

nativewind/components/ui/animated-view.tsx
import { Pressable } from "react-native"
import Animated from "react-native-reanimated"

const AnimatedPressable = Animated.createAnimatedComponent(Pressable)

type AnimatedViewProps =
  | (React.ComponentProps<typeof Animated.View> &
      React.RefAttributes<React.ComponentRef<typeof Animated.View>> & {
        as?: "View"
      })
  | (React.ComponentProps<typeof AnimatedPressable> &
      React.RefAttributes<React.ComponentRef<typeof AnimatedPressable>> & {
        as: "Pressable"
      })

function AnimatedView(props: AnimatedViewProps) {
  1. Copy the source into components/interactions/image-viewer.tsx:
nativewind/components/interactions/image-viewer.tsx
import { cn } from "cn"
import { XIcon } from "phosphor-react-native"
import * as React from "react"
import { Platform, View } from "react-native"
import { FadeIn, FadeOut, ReduceMotion } from "react-native-reanimated"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens"
import * as ImageViewerPrimitive from "@/components/primitives/image-viewer"
import { resolveClassName } from "@/components/primitives/use-render"
import { AnimatedView } from "@/components/ui/animated-view"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

type ImageViewerImage = ImageViewerPrimitive.ImageViewerImage

const FullWindowOverlay =
  1. Update the import paths to match your project.
  1. Install the dependencies:
npx expo install phosphor-react-native react-native-reanimated react-native-safe-area-context react-native-screens react-native-svg react-native-worklets
  1. Add the pieces it builds on: Icon, Text, and the primitives components/primitives/image-viewer.tsx, components/primitives/use-render.tsx, and the animated view components/ui/animated-view.tsx from the next step.

  2. Copy the animated view into components/ui/animated-view.tsx:

stylesheet/components/ui/animated-view.tsx
import { Pressable } from "react-native"
import Animated from "react-native-reanimated"

const AnimatedPressable = Animated.createAnimatedComponent(Pressable)

type AnimatedViewProps =
  | (React.ComponentProps<typeof Animated.View> &
      React.RefAttributes<React.ComponentRef<typeof Animated.View>> & {
        as?: "View"
      })
  | (React.ComponentProps<typeof AnimatedPressable> &
      React.RefAttributes<React.ComponentRef<typeof AnimatedPressable>> & {
        as: "Pressable"
      })

function AnimatedView(props: AnimatedViewProps) {
  1. Copy the source into components/interactions/image-viewer.tsx:
stylesheet/components/interactions/image-viewer.tsx
import { XIcon } from "phosphor-react-native"
import * as React from "react"
import {
  Platform,
  Pressable,
  StyleSheet,
  View,
  type ViewStyle,
} from "react-native"
import { FadeIn, FadeOut, ReduceMotion } from "react-native-reanimated"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens"
import * as ImageViewerPrimitive from "@/components/primitives/image-viewer"
import { resolveStyle } from "@/components/primitives/use-render"
import { AnimatedView } from "@/components/ui/animated-view"
import { Icon } from "@/components/ui/icon"
  1. Update the import paths to match your project.
  1. Target iOS 17 or later.

  2. Add the Swift package to your app:

Package.swift
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
  1. Copy the theme files every view uses:
swift/Theme/Theme.swift
import SwiftUI
import UIKit

struct RGBA: Sendable {
  let red: CGFloat
  let green: CGFloat
  let blue: CGFloat
  let alpha: CGFloat

  init(_ hex: UInt32, _ alpha: CGFloat = 1) {
    red = CGFloat((hex >> 16) & 0xFF) / 255
    green = CGFloat((hex >> 8) & 0xFF) / 255
    blue = CGFloat(hex & 0xFF) / 255
    self.alpha = alpha
  }
swift/Theme/Tokens.swift
import SwiftUI

public enum EsuissColor {
  public static let background = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x000000))
  public static let foreground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let card = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x1D1D1F))
  public static let cardForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let popover = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x1D1D1F))
  public static let popoverForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let primary = Color.adaptive(light: .init(0x0071E3), dark: .init(0x2997FF))
  public static let primaryForeground = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0xFFFFFF))
  public static let secondary = Color.adaptive(light: .init(0x000000, 0.04), dark: .init(0xFFFFFF, 0.1))
  public static let secondaryForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let muted = Color.adaptive(light: .init(0xF5F5F7), dark: .init(0x161617))
  public static let mutedForeground = Color.adaptive(light: .init(0x6E6E73), dark: .init(0xA1A1A6))
  public static let accent = Color.adaptive(light: .init(0x000000, 0.06), dark: .init(0xFFFFFF, 0.1))
  1. Copy the source and every file it uses:
swift/Interactions/ImageViewer.swift
import PhosphorSwift
import SwiftUI

enum ImageViewerMath {
  static let minScale: CGFloat = 1
  static let maxScale: CGFloat = 4
  static let doubleTapScale: CGFloat = 2.5
  static let dismissDistance: CGFloat = 120
  static let fadeDistance: CGFloat = 300

  static func clampScale(_ scale: CGFloat) -> CGFloat {
    min(max(scale, minScale), maxScale)
  }

  static func clampOffset(_ offset: CGSize, scale: CGFloat, size: CGSize) -> CGSize {
    let limitX = max(0, (size.width * scale - size.width) / 2)
swift/Components/Button.swift
import SwiftUI

public struct VariantButtonStyle: ButtonStyle {
  public enum Variant: CaseIterable, Sendable {
    case `default`, outline, secondary, tinted, ghost, plain, destructive, link
  }

  public enum Size: CaseIterable, Sendable {
    case `default`, xs, sm, lg, xl, icon, iconXs, iconSm, iconLg, iconXl

    var height: CGFloat {
      switch self {
      case .xs, .iconXs: 24
      case .sm, .iconSm: 32
      case .default, .icon: 36
      case .lg, .iconLg: 44
swift/Components/ButtonGroup.swift
import SwiftUI

extension EnvironmentValues {
  @Entry var buttonGroup: Axis? = nil
  @Entry var buttonGroupSize: CGSize = .zero
}

enum ButtonGroupSpace: Hashable {
  case group
}

struct ButtonGroupFrame {
  let bounds: Anchor<CGRect>
  let outlined: Bool
}
swift/Components/Carousel.swift
import PhosphorSwift
import SwiftUI

public enum CarouselOrientation: CaseIterable, Sendable {
  case horizontal, vertical
}

public enum CarouselDotsVariant: CaseIterable, Sendable {
  case `default`, plain
}

let carouselAnimation = Animation.timingCurve(0.32, 0.72, 0, 1, duration: 0.5)

enum CarouselMath {
  static func clamp(_ index: Int, count: Int) -> Int {
    count == 0 ? 0 : min(max(index, 0), count - 1)
swift/Components/Overlay.swift
import PhosphorSwift
import Observation
import SwiftUI

public enum PopupAlignment: Sendable, CaseIterable {
  case start, center, end
}

struct OverlayDismiss: Sendable {
  let action: @MainActor @Sendable () -> Void

  @MainActor func callAsFunction() {
    action()
  }
}
swift/Components/TextVariant.swift
import SwiftUI

public enum TextVariant: CaseIterable, Sendable {
  case `default`, h1, h2, h3, h4, p, blockquote, code, lead, large, small, muted

  var style: TextStyle {
    switch self {
    case .h1: .xl3
    case .h2: .xl2
    case .h3: .xl
    case .h4, .lead, .large: .lg
    case .code, .small, .muted: .sm
    default: .base
    }
  }
  1. Add the files to your app target.
  1. Set minSdk to 24 or higher and add the dependencies:
build.gradle.kts
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
implementation("com.adamglin:phosphor-icon-android:1.0.0")
  1. Copy the theme files every view uses:
kotlin/theme/Theme.kt
package com.esuiss.ui.theme

import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.shadow.Shadow as DropShadow
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
kotlin/theme/Tokens.kt
package com.esuiss.ui.theme

import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp

@Immutable
data class Colors(
    val background: Color,
    val foreground: Color,
    val card: Color,
    val cardForeground: Color,
    val popover: Color,
    val popoverForeground: Color,
    val primary: Color,
    val primaryForeground: Color,
  1. Copy the source and every file it uses:
kotlin/interactions/ImageViewer.kt
package com.esuiss.ui.interactions

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.calculateCentroid
import androidx.compose.foundation.gestures.calculatePan
import androidx.compose.foundation.gestures.calculateZoom
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
kotlin/components/Button.kt
package com.esuiss.ui.components

import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
kotlin/components/ButtonGroup.kt
package com.esuiss.ui.components

import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
kotlin/components/Content.kt
package com.esuiss.ui.components

import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp

val LocalContentStyle = compositionLocalOf { TextStyle.Default }

val LocalContentColor = compositionLocalOf { Color.Unspecified }

val LocalIconSize = compositionLocalOf { 20.dp }
kotlin/components/Icon.kt
package com.esuiss.ui.components

import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
import com.esuiss.ui.theme.EsuissTheme

@Composable
fun Icon(
    imageVector: ImageVector,
kotlin/components/Overlay.kt
package com.esuiss.ui.components

import android.os.Build
import android.view.Window
import android.view.WindowManager
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.EnterExitState
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideIn
  1. Update the com.esuiss.ui package names to match your project.

Usage

import { ImageViewer, ImageViewerTrigger, ImageViewerContent } from "@/components/interactions/image-viewer"
<Gallery defaultOpen defaultIndex={1} />

Pass images to ImageViewer, wrap each thumbnail in an ImageViewerTrigger with its index, and render one ImageViewerContent. Give each image an accessibilityLabel. The ImageViewerImage type is exported.

Open state and the current image can be uncontrolled with defaultOpen and defaultIndex, or controlled with open, onOpenChange, index and onIndexChange. useImageViewer returns open, index, count and images.

Examples

Default

iOS
Image Viewer on iOS in light modeImage Viewer on iOS in dark mode
import { Image, View } from "react-native"
import { ImageViewer, ImageViewerContent, type ImageViewerImage, ImageViewerTrigger } from "@/components/interactions/image-viewer"
import { Text } from "@/components/ui/text"

const images: ImageViewerImage[] = [
  {
    source: {
      uri: "https://images.unsplash.com/photo-1588345921523-c2dcdb7f1dcd?w=1200&q=80",
    },
    accessibilityLabel: "Mountain lake at sunrise",
  },
  {
    source: {
      uri: "https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=1200&q=80",
    },
    accessibilityLabel: "Valley with a river",
  },
  {
    source: {
      uri: "https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=1200&q=80",
    },
    accessibilityLabel: "Lake between mountains",
  },
  {
    source: {
      uri: "https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=1200&q=80",
    },
    accessibilityLabel: "Foggy hills",
  },
  {
    source: {
      uri: "https://images.unsplash.com/photo-1441974231531-c6227db76b6e?w=1200&q=80",
    },
    accessibilityLabel: "Forest path",
  },
]

function Gallery({
  defaultOpen,
  defaultIndex,
}: {
  defaultOpen?: boolean
  defaultIndex?: number
}) {
  return (
    <ImageViewer
      images={images}
      defaultOpen={defaultOpen}
      defaultIndex={defaultIndex}
    >
      <View className="gap-3">
        <View className="flex-row flex-wrap gap-2">
          {images.map((image, index) => (
            <ImageViewerTrigger
              key={image.accessibilityLabel}
              index={index}
              accessibilityLabel={image.accessibilityLabel}
              className="aspect-square w-[31%] overflow-hidden rounded-xl bg-muted"
            >
              <Image source={image.source} className="h-full w-full" />
            </ImageViewerTrigger>
          ))}
        </View>
        <Text variant="muted">
          Tap a photo. Pinch or double-tap to zoom, swipe down to close.
        </Text>
      </View>
      <ImageViewerContent />
    </ImageViewer>
  )
}

export function ImageViewerExample() {
  return (
    <Gallery />
  )
}
import EsuissUI
import SwiftUI
import UIKit

struct ImageViewerPhoto {
  let url: URL
  let label: String
}

let imageViewerPhotos = [
  ImageViewerPhoto(url: URL(string: "https://images.unsplash.com/photo-1588345921523-c2dcdb7f1dcd?w=1200&q=80")!, label: "Mountain lake at sunrise"),
  ImageViewerPhoto(url: URL(string: "https://images.unsplash.com/photo-1506744038136-46273834b3fb?w=1200&q=80")!, label: "Valley with a river"),
  ImageViewerPhoto(url: URL(string: "https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=1200&q=80")!, label: "Lake between mountains"),
  ImageViewerPhoto(url: URL(string: "https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=1200&q=80")!, label: "Foggy hills"),
  ImageViewerPhoto(url: URL(string: "https://images.unsplash.com/photo-1441974231531-c6227db76b6e?w=1200&q=80")!, label: "Forest path"),
]

struct ImageViewerGallery: View {
  @State private var open: Bool
  @State private var index: Int
  @State private var loaded: [Int: UIImage] = [:]

  init(open: Bool = false, index: Int = 0) {
    _open = State(initialValue: open)
    _index = State(initialValue: index)
  }

  var images: [Image] {
    imageViewerPhotos.indices.map { page in
      let photo = imageViewerPhotos[page]
      if let cgImage = loaded[page]?.cgImage {
        return Image(cgImage, scale: 1, orientation: .up, label: Text(verbatim: photo.label))
      }
      return Image(uiImage: UIImage())
    }
  }

  var body: some View {
    VStack(alignment: .leading, spacing: 12) {
      GeometryReader { proxy in
        let side = ((proxy.size.width - 16) / 3).rounded(.down)
        FlowRow(spacing: 8) {
          ForEach(imageViewerPhotos.indices, id: \.self) { page in
            Button {
              index = page
              open = true
            } label: {
              thumbnail(page)
                .frame(width: side, height: side)
                .background(EsuissColor.muted)
                .clipShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
            }
            .buttonStyle(.plain)
            .accessibilityLabel(Text(verbatim: imageViewerPhotos[page].label))
          }
        }
      }
      .aspectRatio(3 / 2, contentMode: .fit)
      Text("Tap a photo. Pinch or double-tap to zoom, swipe down to close.")
        .textVariant(.muted)
    }
    .imageViewer(isPresented: $open, images: images, index: $index)
    .task {
      await load()
    }
  }

  @ViewBuilder
  func thumbnail(_ page: Int) -> some View {
    if let image = loaded[page] {
      Image(uiImage: image)
        .resizable()
        .scaledToFill()
    } else {
      EsuissColor.muted
    }
  }

  func load() async {
    await withTaskGroup(of: (Int, UIImage?).self) { group in
      for (page, photo) in imageViewerPhotos.enumerated() {
        group.addTask {
          guard let (data, _) = try? await URLSession.shared.data(from: photo.url) else { return (page, nil) }
          return (page, UIImage(data: data))
        }
      }
      for await (page, image) in group {
        if let image { loaded[page] = image }
      }
    }
  }
}

let group = StoryGroup(.interactions, "Image Viewer", stories: [
  Story("Default") {
    ImageViewerGallery()
  },
  Story("OpenByDefault") {
    ImageViewerGallery(open: true, index: 1)
  },
]
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant
import com.esuiss.ui.interactions.ImageViewer
import com.esuiss.ui.interactions.ImageViewerImage
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Radius

@Composable
fun ImageViewerExample() {
    ImageViewerGallery(defaultOpen = false, defaultIndex = 0)
}

class ImageViewerGradient(private val from: Color, private val to: Color, private val size: Size) : Painter() {
    override val intrinsicSize: Size
        get() = size

    override fun DrawScope.onDraw() {
        drawRect(Brush.linearGradient(listOf(from, to), start = Offset.Zero, end = Offset(this.size.width, this.size.height)))
    }
}

val imageViewerLabels = listOf(
    "Mountain lake at sunrise",
    "Valley with a river",
    "Lake between mountains",
    "Foggy hills",
    "Forest path",
)

@Composable
fun ImageViewerGallery(defaultOpen: Boolean, defaultIndex: Int) {
    val colors = EsuissTheme.colors
    var open by rememberSaveable { mutableStateOf(defaultOpen) }
    var index by rememberSaveable { mutableIntStateOf(defaultIndex) }
    val tones = listOf(
        Palette.orange500 to Palette.pink500,
        Palette.teal500 to Palette.blue500,
        Palette.indigo500 to Palette.cyan500,
        Palette.zinc500 to Palette.teal400,
        Palette.green500 to Palette.amber500,
    )
    val images = remember(tones) {
        imageViewerLabels.mapIndexed { position, label ->
            val (from, to) = tones[position]
            ImageViewerImage(ImageViewerGradient(from, to, Size(1200f, 800f)), contentDescription = label)
        }
    }
    val shape = RoundedCornerShape(Radius.xl)
    Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
        FlowRow(
            modifier = Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.spacedBy(8.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp),
        ) {
            images.forEachIndexed { position, image ->
                Image(
                    painter = image.painter,
                    contentDescription = null,
                    contentScale = ContentScale.Crop,
                    modifier = Modifier
                        .fillMaxWidth(0.31f)
                        .aspectRatio(1f)
                        .clip(shape)
                        .background(colors.muted, shape)
                        .semantics { contentDescription = imageViewerLabels[position] }
                        .clickable(role = Role.Button) {
                            index = position
                            open = true
                        },
                )
            }
        }
        Text("Tap a photo. Pinch or double-tap to zoom, swipe down to close.", variant = TextVariant.Muted)
    }
    ImageViewer(
        open = open,
        onOpenChange = { open = it },
        images = images,
        index = index,
        onIndexChange = { index = it },
    )
}

API Reference

01

ImageViewer

The root that holds the images, open state and current index.

PropTypeDefaultDescription
onOpenChange(open: boolean) => void-Called when the viewer opens or closes.
onIndexChange(index: number) => void-Called with the new index after paging.

Also accepts the props of React.ComponentProps<typeof ImageViewerPrimitive.Root>.

02

ImageViewerTrigger

A pressable thumbnail that opens the viewer at its index.

Also accepts the props of React.ComponentProps<typeof ImageViewerPrimitive.Trigger>.

03

ImageViewerContent

The full-screen viewer with the pages, counter and close button.

PropTypeDefaultDescription
portalHoststring-Name of a portal host to render into.
closeLabelstring"Close"Accessibility label of the close button. Defaults to Close.
indexLabel(index: number, count: number) => stringdefaultIndexLabelFormats the counter from the index and count. Defaults to n of count.
classNamestring-

Accessibility

  • The viewer is modal for screen readers, and the escape gesture closes it.
  • The current page is adjustable: swiping up or down with a screen reader moves between images. It is labelled by the image's accessibilityLabel, or Image n, with the counter as its value.
  • Triggers have the button role and expose their expanded state.

Notes

  • The viewer fades in and out, and paging and zoom animations respect the system reduce motion setting.
  • Colors use the bg-black and text-white tokens, so the viewer stays dark in light mode.
  • On iOS the viewer renders in a full-window overlay so it appears above native screens.
NextIslandPreviousCard Stack

On this page

  1. 01Installation
    • Manual
  2. 02Usage
  3. 03Examples
    • Default
  4. 04API Reference
    • ImageViewer
    • ImageViewerTrigger
    • ImageViewerContent
  5. 05Accessibility
  6. 06Notes