# Grid Carousel

A horizontally scrolling carousel of product cards with previous and next controls.

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

Source: https://ui.suiss.app/docs/blocks/grid-carousel

**React**

**Example**

```tsx title="components/blocks/grid-carousel.tsx"
"use client"

import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from "@/components/ui/carousel"

const photo = (id: string) =>
  `https://images.unsplash.com/photo-${id}?w=800&q=80&auto=format&fit=crop`

const products = [
  {
    kicker: "New",
    name: "Phone Pro",
    tagline: "Pro through and through.",
    price: "$999",
    image: photo("1714972384975-fbd4f19b1bb3"),
    alt: "An orange phone on a warm gradient",
  },
  {
    kicker: "New",
    name: "Book Air",
    tagline: "Supercharged by S5.",
    price: "$1,099",
    image: photo("1644792863360-40fa85ea52e7"),
    alt: "A laptop on a wooden sideboard",
  },
  {
    name: "Watch Series 11",
    tagline: "Your best health companion.",
    price: "$399",
    image: photo("1696688713460-de12ac76ebc6"),
    alt: "A black smartwatch on a dark fabric",
  },
  {
    kicker: "New",
    name: "Phone Air",
    tagline: "The thinnest phone ever.",
    price: "$899",
    image: photo("1664883410931-2edffc8be5e6"),
    alt: "A silver phone on a light grey background",
  },
  {
    name: "Phone",
    tagline: "More colors. More power.",
    price: "$799",
    image: photo("1714578187196-29775454aa39"),
    alt: "A phone floating among blue spheres",
  },
  {
    name: "Pods Studio",
    tagline: "Active Noise Cancellation.",
    price: "$549",
    image: photo("1599669454699-248893623440"),
    alt: "Black over-ear headphones on a dark background",
  },
]

export function GridCarousel() {
  return (
    <section className="flex flex-col gap-5.5 bg-muted pt-10 pb-5">
      <h2 className="px-6 text-3xl font-semibold tracking-tight text-balance md:px-12">
        The latest.{" "}
        <span className="text-muted-foreground">
          Fresh favorites of the season.
        </span>
      </h2>
      <Carousel
        opts={{ align: "start" }}
        aria-label="Latest products"
        className="flex flex-col gap-0"
      >
        <CarouselContent className="ms-0 gap-5 px-6 pt-2 pb-6 md:px-12">
          {products.map((product) => (
            <CarouselItem key={product.name} className="basis-auto ps-0">
              <a
                href={`#${product.name.toLowerCase().replaceAll(" ", "-")}`}
                className="flex h-110 w-[min(22.5rem,calc(100vw-4rem))] flex-col overflow-hidden rounded-[1.125rem] bg-background shadow-[2px_4px_12px_rgb(0_0_0/0.08)] transition-[scale,box-shadow] duration-300 ease-[cubic-bezier(0,0,0.5,1)] outline-none hover:scale-101 hover:shadow-[2px_4px_16px_rgb(0_0_0/0.16)] focus-visible:ring-3 focus-visible:ring-ring/50 motion-reduce:transition-none"
              >
                <div className="flex flex-col gap-1 px-7 pt-7">
                  {product.kicker && (
                    <span className="text-xs font-semibold tracking-wide text-[color-mix(in_oklab,var(--color-orange-500),var(--foreground)_45%)] uppercase dark:text-orange-500">
                      {product.kicker}
                    </span>
                  )}
                  <span className="text-3xl font-semibold tracking-tight">
                    {product.name}
                  </span>
                  <span className="text-lg">{product.tagline}</span>
                  <span className="mt-1.5 text-sm text-muted-foreground">
                    From {product.price}
                  </span>
                </div>
                <img
                  src={product.image}
                  alt={product.alt}
                  className="m-5 min-h-0 flex-1 rounded-xl bg-secondary object-cover"
                />
              </a>
            </CarouselItem>
          ))}
        </CarouselContent>
        <div className="flex justify-end gap-3 px-6 md:px-12">
          <CarouselPrevious
            variant="secondary"
            size="icon"
            className="static inset-auto my-0"
          />
          <CarouselNext
            variant="secondary"
            size="icon"
            className="static inset-auto my-0"
          />
        </div>
      </Carousel>
    </section>
  )
}
```

A horizontally scrolling row of product cards under a two-tone heading, each with an optional kicker, name, tagline, starting price and image. Previous and next buttons sit below the track, and cards shrink to fit narrow screens. With no state of its own, it is a simple base for latest products, featured items or related content rows.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/grid-carousel
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add blocks grid-carousel
```

### Manual

1. Add the pieces it builds on: [Carousel](https://ui.suiss.app/docs/components/carousel).

2. Copy the source into `components/blocks/grid-carousel.tsx`:

```tsx title="components/blocks/grid-carousel.tsx"
"use client"

import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from "@/components/ui/carousel"

const photo = (id: string) =>
  `https://images.unsplash.com/photo-${id}?w=800&q=80&auto=format&fit=crop`

const products = [
  {
    kicker: "New",
    name: "Phone Pro",
    tagline: "Pro through and through.",
    price: "$999",
    image: photo("1714972384975-fbd4f19b1bb3"),
    alt: "An orange phone on a warm gradient",
  },
  {
    kicker: "New",
    name: "Book Air",
    tagline: "Supercharged by S5.",
    price: "$1,099",
    image: photo("1644792863360-40fa85ea52e7"),
    alt: "A laptop on a wooden sideboard",
  },
  {
    name: "Watch Series 11",
    tagline: "Your best health companion.",
    price: "$399",
    image: photo("1696688713460-de12ac76ebc6"),
    alt: "A black smartwatch on a dark fabric",
  },
  {
    kicker: "New",
    name: "Phone Air",
    tagline: "The thinnest phone ever.",
    price: "$899",
    image: photo("1664883410931-2edffc8be5e6"),
    alt: "A silver phone on a light grey background",
  },
  {
    name: "Phone",
    tagline: "More colors. More power.",
    price: "$799",
    image: photo("1714578187196-29775454aa39"),
    alt: "A phone floating among blue spheres",
  },
  {
    name: "Pods Studio",
    tagline: "Active Noise Cancellation.",
    price: "$549",
    image: photo("1599669454699-248893623440"),
    alt: "Black over-ear headphones on a dark background",
  },
]

export function GridCarousel() {
  return (
    <section className="flex flex-col gap-5.5 bg-muted pt-10 pb-5">
      <h2 className="px-6 text-3xl font-semibold tracking-tight text-balance md:px-12">
        The latest.{" "}
        <span className="text-muted-foreground">
          Fresh favorites of the season.
        </span>
      </h2>
      <Carousel
        opts={{ align: "start" }}
        aria-label="Latest products"
        className="flex flex-col gap-0"
      >
        <CarouselContent className="ms-0 gap-5 px-6 pt-2 pb-6 md:px-12">
          {products.map((product) => (
            <CarouselItem key={product.name} className="basis-auto ps-0">
              <a
                href={`#${product.name.toLowerCase().replaceAll(" ", "-")}`}
                className="flex h-110 w-[min(22.5rem,calc(100vw-4rem))] flex-col overflow-hidden rounded-[1.125rem] bg-background shadow-[2px_4px_12px_rgb(0_0_0/0.08)] transition-[scale,box-shadow] duration-300 ease-[cubic-bezier(0,0,0.5,1)] outline-none hover:scale-101 hover:shadow-[2px_4px_16px_rgb(0_0_0/0.16)] focus-visible:ring-3 focus-visible:ring-ring/50 motion-reduce:transition-none"
              >
                <div className="flex flex-col gap-1 px-7 pt-7">
                  {product.kicker && (
                    <span className="text-xs font-semibold tracking-wide text-[color-mix(in_oklab,var(--color-orange-500),var(--foreground)_45%)] uppercase dark:text-orange-500">
                      {product.kicker}
                    </span>
                  )}
                  <span className="text-3xl font-semibold tracking-tight">
                    {product.name}
                  </span>
                  <span className="text-lg">{product.tagline}</span>
                  <span className="mt-1.5 text-sm text-muted-foreground">
                    From {product.price}
                  </span>
                </div>
                <img
                  src={product.image}
                  alt={product.alt}
                  className="m-5 min-h-0 flex-1 rounded-xl bg-secondary object-cover"
                />
              </a>
            </CarouselItem>
          ))}
        </CarouselContent>
        <div className="flex justify-end gap-3 px-6 md:px-12">
          <CarouselPrevious
            variant="secondary"
            size="icon"
            className="static inset-auto my-0"
          />
          <CarouselNext
            variant="secondary"
            size="icon"
            className="static inset-auto my-0"
          />
        </div>
      </Carousel>
    </section>
  )
}
```

3. Update the import paths to match your project.

## Usage

```tsx
import { GridCarousel } from "@/components/blocks/grid-carousel"
```

```tsx
<GridCarousel />
```

Replace `products`; each has an optional kicker, a name, tagline, formatted price, image and alt text. Card links are hash anchors built from the name, so point them at your product pages.

## Notes

Images are Unsplash placeholders with descriptive alt text.

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/blocks-grid-carousel--default.light.webp)

```tsx title="uniwind/components/blocks/grid-carousel.tsx"
import { Image, Pressable, ScrollView, View } from "react-native"
import { FadeInDown, ReduceMotion } from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import {
  Carousel,
  CarouselContent,
  CarouselItem,
  useCarousel,
} from "@/components/ui/carousel"
import { PageIndicator } from "@/components/ui/page-indicator"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"

const photo = (id: string) =>
  `https://images.unsplash.com/photo-${id}?w=800&q=80&auto=format&fit=crop`

const PRODUCTS: {
  kicker?: string
  name: string
  tagline: string
  price: string
  image: string
  alt: string
}[] = [
  {
    kicker: "New",
    name: "Phone Pro",
    tagline: "Pro through and through.",
    price: "$999",
    image: photo("1714972384975-fbd4f19b1bb3"),
    alt: "An orange phone on a warm gradient",
  },
  {
    kicker: "New",
    name: "Book Air",
    tagline: "Supercharged by S5.",
    price: "$1,099",
    image: photo("1644792863360-40fa85ea52e7"),
    alt: "A laptop on a wooden sideboard",
  },
  {
    name: "Watch Series 11",
    tagline: "Your best health companion.",
    price: "$399",
    image: photo("1696688713460-de12ac76ebc6"),
    alt: "A black smartwatch on a dark fabric",
  },
  {
    kicker: "New",
    name: "Phone Air",
    tagline: "The thinnest phone ever.",
    price: "$899",
    image: photo("1664883410931-2edffc8be5e6"),
    alt: "A silver phone on a light grey background",
  },
  {
    name: "Phone",
    tagline: "More colors. More power.",
    price: "$799",
    image: photo("1714578187196-29775454aa39"),
    alt: "A phone floating among blue spheres",
  },
  {
    name: "Pods Studio",
    tagline: "Active Noise Cancellation.",
    price: "$549",
    image: photo("1599669454699-248893623440"),
    alt: "Black over-ear headphones on a dark background",
  },
]

function CarouselPageIndicator() {
  const { scrollSnaps, selectedIndex, scrollTo } = useCarousel()
  return (
    <PageIndicator
      accessibilityLabel="Latest products"
      count={scrollSnaps.length}
      index={selectedIndex}
      onIndexChange={(index) => scrollTo(index)}
      className="self-center"
    />
  )
}

function GridCarousel({
  onSelectProduct,
}: {
  onSelectProduct?: (name: string) => void
}) {
  return (
    <Screen variant="secondary">
      <ScrollView contentContainerClassName="gap-5.5 pt-10 pb-5">
        <AnimatedView
          entering={FadeInDown.duration(600).reduceMotion(ReduceMotion.System)}
          className="px-5"
        >
          <Text role="heading" className="text-3xl font-semibold">
            The latest.{" "}
            <Text className="text-3xl font-semibold text-muted-foreground">
              Fresh favorites of the season.
            </Text>
          </Text>
        </AnimatedView>
        <Carousel
          opts={{ align: "center" }}
          accessibilityLabel="Latest products"
          className="gap-2"
        >
          <CarouselContent
            className="ms-0"
            contentContainerClassName="gap-4 px-5 pt-2 pb-6"
          >
            {PRODUCTS.map((product) => (
              <CarouselItem key={product.name} basis={0.86} className="ps-0">
                <Pressable
                  role="link"
                  accessibilityLabel={`${product.kicker ? `${product.kicker}, ` : ""}${product.name}. ${product.tagline} From ${product.price}`}
                  onPress={() => onSelectProduct?.(product.name)}
                  className="h-110 rounded-[1.125rem] bg-background shadow-md active:opacity-80"
                >
                  <View className="gap-1 px-7 pt-7">
                    {product.kicker ? (
                      <Text className="text-xs font-semibold text-orange-500 uppercase">
                        {product.kicker}
                      </Text>
                    ) : null}
                    <Text className="text-3xl font-semibold">
                      {product.name}
                    </Text>
                    <Text className="text-lg">{product.tagline}</Text>
                    <Text className="mt-1.5 text-sm text-muted-foreground">
                      From {product.price}
                    </Text>
                  </View>
                  <Image
                    source={{ uri: product.image }}
                    resizeMode="cover"
                    accessibilityIgnoresInvertColors
                    accessibilityLabel={product.alt}
                    className="m-5 min-h-0 flex-1 rounded-xl bg-secondary"
                  />
                </Pressable>
              </CarouselItem>
            ))}
          </CarouselContent>
          <CarouselPageIndicator />
        </Carousel>
      </ScrollView>
    </Screen>
  )
}

export { GridCarousel }
```

```swift title="swift/Blocks/GridCarousel.swift"
import PhosphorSwift
import SwiftUI

public struct GridCarousel: View {
  struct Product: Identifiable {
    var kicker: String?
    let name: String
    let tagline: String
    let price: String
    let image: URL?
    let alt: String

    var id: String { name }
  }

  static func photo(_ id: String) -> URL? {
    URL(string: "https://images.unsplash.com/photo-\(id)?w=800&q=80&auto=format&fit=crop")
  }

  static var products: [Product] {
    [
      Product(kicker: "New", name: "Phone Pro", tagline: "Pro through and through.", price: "$999", image: photo("1714972384975-fbd4f19b1bb3"), alt: "An orange phone on a warm gradient"),
      Product(kicker: "New", name: "Book Air", tagline: "Supercharged by S5.", price: "$1,099", image: photo("1644792863360-40fa85ea52e7"), alt: "A laptop on a wooden sideboard"),
      Product(name: "Watch Series 11", tagline: "Your best health companion.", price: "$399", image: photo("1696688713460-de12ac76ebc6"), alt: "A black smartwatch on a dark fabric"),
      Product(kicker: "New", name: "Phone Air", tagline: "The thinnest phone ever.", price: "$899", image: photo("1664883410931-2edffc8be5e6"), alt: "A silver phone on a light grey background"),
      Product(name: "Phone", tagline: "More colors. More power.", price: "$799", image: photo("1714578187196-29775454aa39"), alt: "A phone floating among blue spheres"),
      Product(name: "Pods Studio", tagline: "Active Noise Cancellation.", price: "$549", image: photo("1599669454699-248893623440"), alt: "Black over-ear headphones on a dark background"),
    ]
  }

  private var onSelectProduct: ((String) -> Void)?
  @State private var controller = CarouselController()
  private var initialIndex = 0

  public init(onSelectProduct: ((_ product: String) -> Void)? = nil) {
    self.onSelectProduct = onSelectProduct
  }

  init(index: Int) {
    initialIndex = index
  }

  public var body: some View {
    Screen(background: .muted) {
      KeyboardScrollView {
        VStack(alignment: .leading, spacing: 22) {
          Text("\(Text("The latest.")) \(Text("Fresh favorites of the season.").foregroundStyle(EsuissColor.mutedForeground))")
            .scaledTextStyle(.xl2, weight: .semibold)
            .fixedSize(horizontal: false, vertical: true)
            .padding(.horizontal, 24)
            .accessibilityAddTraits(.isHeader)
          Carousel(controller: controller) {
            CarouselContent(spacing: 16) {
              ForEach(Self.products) { product in
                CarouselItem(basis: 0.86) {
                  GridCarouselCard(product: product) {
                    onSelectProduct?(product.name)
                  }
                }
              }
            }
            .contentMargins(.horizontal, 24, for: .scrollContent)
            .contentMargins(.vertical, 8, for: .scrollContent)
            .padding(.bottom, 8)
            PageIndicator(count: Self.products.count, index: controller.selectedIndex) { index in
              controller.scrollTo(index)
            }
          }
          .accessibilityLabel(Text("Latest products"))
        }
        .padding(.top, 40)
        .padding(.bottom, 20)
      }
    }
    .task {
      guard initialIndex > 0 else { return }
      try? await Task.sleep(for: .milliseconds(100))
      controller.scrollTo(initialIndex, animated: false)
    }
  }
}

private struct GridCarouselCard: View {
  let product: GridCarousel.Product
  let action: () -> Void

  var body: some View {
    Button(action: action) {
      VStack(alignment: .leading, spacing: 0) {
        VStack(alignment: .leading, spacing: 4) {
          if let kicker = product.kicker {
            Text(LocalizedStringKey(kicker))
              .textCase(.uppercase)
              .scaledFont(.xs, weight: .semibold)
              .tracking(0.6)
              .foregroundStyle(Color.adaptive(light: Palette.amber600, dark: Palette.amber400))
          }
          Text(verbatim: product.name)
            .scaledTextStyle(.xl2, weight: .semibold)
          Text(LocalizedStringKey(product.tagline))
            .scaledFont(.lg)
          Text("From \(product.price)")
            .scaledFont(.sm)
            .foregroundStyle(EsuissColor.mutedForeground)
            .padding(.top, 6)
        }
        .foregroundStyle(EsuissColor.foreground)
        .multilineTextAlignment(.leading)
        .fixedSize(horizontal: false, vertical: true)
        .padding(.horizontal, 28)
        .padding(.top, 28)
        EsuissColor.secondary
          .overlay {
            AsyncImage(url: product.image) { phase in
              if let image = phase.image {
                image.resizable().scaledToFill()
              }
            }
          }
          .clipShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
          .padding(20)
          .accessibilityLabel(Text(LocalizedStringKey(product.alt)))
      }
      .frame(maxWidth: .infinity, alignment: .leading)
      .frame(height: 440)
      .background(EsuissColor.background, in: RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous))
      .shadow(Shadow.md)
      .contentShape(RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous))
    }
    .buttonStyle(GridCarouselCardStyle())
    .accessibilityElement(children: .combine)
    .accessibilityAddTraits(.isButton)
  }
}

private struct GridCarouselCardStyle: ButtonStyle {
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  func makeBody(configuration: Configuration) -> some View {
    configuration.label
      .scaleEffect(configuration.isPressed && !reduceMotion ? 0.98 : 1)
      .animation(reduceMotion ? nil : .easeOut(duration: 0.2), value: configuration.isPressed)
  }
}
```

```kt title="kotlin/blocks/GridCarousel.kt"
package com.esuiss.ui.blocks

import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Light
import com.adamglin.phosphoricons.light.DeviceMobile
import com.adamglin.phosphoricons.light.DeviceMobileSpeaker
import com.adamglin.phosphoricons.light.Headphones
import com.adamglin.phosphoricons.light.Laptop
import com.adamglin.phosphoricons.light.Watch
import com.esuiss.ui.components.Carousel
import com.esuiss.ui.components.CarouselContent
import com.esuiss.ui.components.CarouselItem
import com.esuiss.ui.components.CarouselNext
import com.esuiss.ui.components.CarouselPrevious
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.PageIndicator
import com.esuiss.ui.components.Screen
import com.esuiss.ui.components.ScreenVariant
import com.esuiss.ui.components.rememberCarouselState
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Shadows
import com.esuiss.ui.theme.Typography
import com.esuiss.ui.theme.shadow

private class CarouselProduct(
    val name: String,
    val tagline: String,
    val price: String,
    val icon: ImageVector,
    val alt: String,
    val kicker: String? = null,
)

private val CarouselProducts = listOf(
    CarouselProduct("Phone Pro", "Pro through and through.", "$999", PhosphorIcons.Light.DeviceMobile, "An orange phone on a warm gradient", "New"),
    CarouselProduct("Book Air", "Supercharged by S5.", "$1,099", PhosphorIcons.Light.Laptop, "A laptop on a wooden sideboard", "New"),
    CarouselProduct("Watch Series 11", "Your best health companion.", "$399", PhosphorIcons.Light.Watch, "A black smartwatch on a dark fabric"),
    CarouselProduct("Phone Air", "The thinnest phone ever.", "$899", PhosphorIcons.Light.DeviceMobileSpeaker, "A silver phone on a light grey background", "New"),
    CarouselProduct("Phone", "More colors. More power.", "$799", PhosphorIcons.Light.DeviceMobile, "A phone floating among blue spheres"),
    CarouselProduct("Pods Studio", "Active Noise Cancellation.", "$549", PhosphorIcons.Light.Headphones, "Black over-ear headphones on a dark background"),
)

@Composable
fun GridCarousel(
    modifier: Modifier = Modifier,
    onSelectProduct: (product: String) -> Unit = {},
) {
    val colors = EsuissTheme.colors
    val state = rememberCarouselState(CarouselProducts.size)

    Screen(modifier, variant = ScreenVariant.Secondary) {
        Column(
            Modifier
                .fillMaxSize()
                .verticalScroll(rememberScrollState())
                .padding(top = 40.dp, bottom = 20.dp),
            verticalArrangement = Arrangement.spacedBy(14.dp),
        ) {
            BasicText(
                buildAnnotatedString {
                    append("The latest. ")
                    withStyle(SpanStyle(color = colors.mutedForeground)) { append("Fresh favorites of the season.") }
                },
                modifier = Modifier
                    .padding(horizontal = 24.dp)
                    .semantics { heading() },
                style = Typography.xl2.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold),
            )
            Carousel(state, modifier = Modifier.semantics { contentDescription = "Latest products" }) {
                CarouselContent(
                    contentPadding = PaddingValues(start = 24.dp, end = 48.dp, top = 8.dp, bottom = 24.dp),
                    pageSpacing = 16.dp,
                ) { page ->
                    CarouselItem {
                        CarouselCard(CarouselProducts[page]) { onSelectProduct(CarouselProducts[page].name) }
                    }
                }
                Row(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(horizontal = 24.dp),
                    horizontalArrangement = Arrangement.spacedBy(12.dp),
                    verticalAlignment = Alignment.CenterVertically,
                ) {
                    PageIndicator(
                        count = CarouselProducts.size,
                        index = state.selectedIndex,
                        onIndexChange = { state.scrollTo(it) },
                        accessibilityLabel = "Latest products",
                    )
                    Spacer(Modifier.weight(1f))
                    CarouselPrevious()
                    CarouselNext()
                }
            }
        }
    }
}

@Composable
private fun CarouselCard(product: CarouselProduct, onClick: () -> Unit) {
    val colors = EsuissTheme.colors
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val scale by animateFloatAsState(if (pressed) 0.98f else 1f, tween(300), label = "press")
    val shape = RoundedCornerShape(18.dp)
    Column(
        Modifier
            .fillMaxWidth()
            .height(440.dp)
            .graphicsLayer {
                scaleX = scale
                scaleY = scale
            }
            .shadow(Shadows.md, shape)
            .clip(shape)
            .background(colors.background, shape)
            .clickable(interactionSource = interaction, indication = null, role = Role.Button, onClick = onClick),
    ) {
        Column(
            Modifier.padding(start = 28.dp, end = 28.dp, top = 28.dp),
            verticalArrangement = Arrangement.spacedBy(4.dp),
        ) {
            if (product.kicker != null) {
                BasicText(
                    product.kicker.uppercase(),
                    style = Typography.xs.copy(color = if (colors.isDark) Palette.amber400 else Palette.amber600, fontWeight = FontWeight.SemiBold, letterSpacing = 0.025.em),
                )
            }
            BasicText(product.name, style = Typography.xl3.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold))
            BasicText(product.tagline, style = Typography.lg.copy(color = colors.foreground))
            BasicText(
                "From ${product.price}",
                modifier = Modifier.padding(top = 6.dp),
                style = Typography.sm.copy(color = colors.mutedForeground),
            )
        }
        Box(
            Modifier
                .padding(20.dp)
                .weight(1f)
                .fillMaxWidth()
                .background(colors.muted, RoundedCornerShape(12.dp))
                .clearAndSetSemantics { contentDescription = product.alt },
            contentAlignment = Alignment.Center,
        ) {
            Icon(product.icon, contentDescription = null, size = 96.dp, tint = colors.mutedForeground)
        }
    }
}
```

Composes `Screen`, `Carousel` with `CarouselPageIndicator`, `Text` and product photos. Each card shows an optional kicker, name, tagline and starting price, and the whole card is pressable.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/grid-carousel
```

See [Installation](https://ui.suiss.app/docs/installation) for the `components.json` setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add grid-carousel
```

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add grid-carousel --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add grid-carousel --platform kotlin
```

The CLI also adds what it builds on: [Carousel](https://ui.suiss.app/docs/components/carousel), [Page Indicator](https://ui.suiss.app/docs/components/page-indicator), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text). On React Native it also adds the animated view `components/ui/animated-view.tsx`.

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install react-native-reanimated react-native-worklets
```

2. Add the pieces it builds on: [Carousel](https://ui.suiss.app/docs/components/carousel), [Page Indicator](https://ui.suiss.app/docs/components/page-indicator), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text), and the animated view `components/ui/animated-view.tsx` from the next step.

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

- `uniwind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/uniwind/components/ui/animated-view.tsx)

4. Copy the source into `components/blocks/grid-carousel.tsx`:

- `uniwind/components/blocks/grid-carousel.tsx`: shown above

5. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install react-native-reanimated react-native-worklets
```

2. Add the pieces it builds on: [Carousel](https://ui.suiss.app/docs/components/carousel), [Page Indicator](https://ui.suiss.app/docs/components/page-indicator), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text), and the animated view `components/ui/animated-view.tsx` from the next step.

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

- `nativewind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/nativewind/components/ui/animated-view.tsx)

4. Copy the source into `components/blocks/grid-carousel.tsx`:

```tsx title="nativewind/components/blocks/grid-carousel.tsx"
import { Image, Pressable, ScrollView, View } from "react-native"
import { FadeInDown, ReduceMotion } from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import {
  Carousel,
  CarouselContent,
  CarouselItem,
  useCarousel,
} from "@/components/ui/carousel"
import { PageIndicator } from "@/components/ui/page-indicator"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"

const photo = (id: string) =>
  `https://images.unsplash.com/photo-${id}?w=800&q=80&auto=format&fit=crop`

const PRODUCTS: {
  kicker?: string
  name: string
  tagline: string
  price: string
  image: string
  alt: string
}[] = [
  {
    kicker: "New",
    name: "Phone Pro",
    tagline: "Pro through and through.",
    price: "$999",
    image: photo("1714972384975-fbd4f19b1bb3"),
    alt: "An orange phone on a warm gradient",
  },
  {
    kicker: "New",
    name: "Book Air",
    tagline: "Supercharged by S5.",
    price: "$1,099",
    image: photo("1644792863360-40fa85ea52e7"),
    alt: "A laptop on a wooden sideboard",
  },
  {
    name: "Watch Series 11",
    tagline: "Your best health companion.",
    price: "$399",
    image: photo("1696688713460-de12ac76ebc6"),
    alt: "A black smartwatch on a dark fabric",
  },
  {
    kicker: "New",
    name: "Phone Air",
    tagline: "The thinnest phone ever.",
    price: "$899",
    image: photo("1664883410931-2edffc8be5e6"),
    alt: "A silver phone on a light grey background",
  },
  {
    name: "Phone",
    tagline: "More colors. More power.",
    price: "$799",
    image: photo("1714578187196-29775454aa39"),
    alt: "A phone floating among blue spheres",
  },
  {
    name: "Pods Studio",
    tagline: "Active Noise Cancellation.",
    price: "$549",
    image: photo("1599669454699-248893623440"),
    alt: "Black over-ear headphones on a dark background",
  },
]

function CarouselPageIndicator() {
  const { scrollSnaps, selectedIndex, scrollTo } = useCarousel()
  return (
    <PageIndicator
      accessibilityLabel="Latest products"
      count={scrollSnaps.length}
      index={selectedIndex}
      onIndexChange={(index) => scrollTo(index)}
      className="self-center"
    />
  )
}

function GridCarousel({
  onSelectProduct,
}: {
  onSelectProduct?: (name: string) => void
}) {
  return (
    <Screen variant="secondary">
      <ScrollView contentContainerClassName="gap-[1.375rem] pt-10 pb-5">
        <AnimatedView
          entering={FadeInDown.duration(600).reduceMotion(ReduceMotion.System)}
          className="px-5"
        >
          <Text role="heading" className="text-3xl font-semibold">
            The latest.{" "}
            <Text className="text-3xl font-semibold text-muted-foreground">
              Fresh favorites of the season.
            </Text>
          </Text>
        </AnimatedView>
        <Carousel
          opts={{ align: "center" }}
          accessibilityLabel="Latest products"
          className="gap-2"
        >
          <CarouselContent
            className="ms-0"
            contentContainerClassName="gap-4 px-5 pt-2 pb-6"
          >
            {PRODUCTS.map((product) => (
              <CarouselItem key={product.name} basis={0.86} className="ps-0">
                <Pressable
                  role="link"
                  accessibilityLabel={`${product.kicker ? `${product.kicker}, ` : ""}${product.name}. ${product.tagline} From ${product.price}`}
                  onPress={() => onSelectProduct?.(product.name)}
                  className="h-[27.5rem] rounded-[1.125rem] bg-background shadow-md active:opacity-80"
                >
                  <View className="gap-1 px-7 pt-7">
                    {product.kicker ? (
                      <Text className="text-xs font-semibold text-orange-500 uppercase">
                        {product.kicker}
                      </Text>
                    ) : null}
                    <Text className="text-3xl font-semibold">
                      {product.name}
                    </Text>
                    <Text className="text-lg">{product.tagline}</Text>
                    <Text className="mt-1.5 text-sm text-muted-foreground">
                      From {product.price}
                    </Text>
                  </View>
                  <Image
                    source={{ uri: product.image }}
                    resizeMode="cover"
                    accessibilityIgnoresInvertColors
                    accessibilityLabel={product.alt}
                    className="m-5 min-h-0 flex-1 rounded-xl bg-secondary"
                  />
                </Pressable>
              </CarouselItem>
            ))}
          </CarouselContent>
          <CarouselPageIndicator />
        </Carousel>
      </ScrollView>
    </Screen>
  )
}

export { GridCarousel }
```

5. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

```bash title="Terminal"
npx expo install react-native-reanimated react-native-worklets
```

2. Add the pieces it builds on: [Carousel](https://ui.suiss.app/docs/components/carousel), [Page Indicator](https://ui.suiss.app/docs/components/page-indicator), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text), and the animated view `components/ui/animated-view.tsx` from the next step.

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

- `stylesheet/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/stylesheet/components/ui/animated-view.tsx)

4. Copy the source into `components/blocks/grid-carousel.tsx`:

```tsx title="stylesheet/components/blocks/grid-carousel.tsx"
import { Image, Pressable, ScrollView, StyleSheet, View } from "react-native"
import { FadeInDown, ReduceMotion } from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import {
  Carousel,
  CarouselContent,
  CarouselItem,
  useCarousel,
} from "@/components/ui/carousel"
import { PageIndicator } from "@/components/ui/page-indicator"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"
import {
  fontSize,
  palette,
  radius,
  shadow,
  useTheme,
} from "@/components/ui/theme"

const photo = (id: string) =>
  `https://images.unsplash.com/photo-${id}?w=800&q=80&auto=format&fit=crop`

const PRODUCTS: {
  kicker?: string
  name: string
  tagline: string
  price: string
  image: string
  alt: string
}[] = [
  {
    kicker: "New",
    name: "Phone Pro",
    tagline: "Pro through and through.",
    price: "$999",
    image: photo("1714972384975-fbd4f19b1bb3"),
    alt: "An orange phone on a warm gradient",
  },
  {
    kicker: "New",
    name: "Book Air",
    tagline: "Supercharged by S5.",
    price: "$1,099",
    image: photo("1644792863360-40fa85ea52e7"),
    alt: "A laptop on a wooden sideboard",
  },
  {
    name: "Watch Series 11",
    tagline: "Your best health companion.",
    price: "$399",
    image: photo("1696688713460-de12ac76ebc6"),
    alt: "A black smartwatch on a dark fabric",
  },
  {
    kicker: "New",
    name: "Phone Air",
    tagline: "The thinnest phone ever.",
    price: "$899",
    image: photo("1664883410931-2edffc8be5e6"),
    alt: "A silver phone on a light grey background",
  },
  {
    name: "Phone",
    tagline: "More colors. More power.",
    price: "$799",
    image: photo("1714578187196-29775454aa39"),
    alt: "A phone floating among blue spheres",
  },
  {
    name: "Pods Studio",
    tagline: "Active Noise Cancellation.",
    price: "$549",
    image: photo("1599669454699-248893623440"),
    alt: "Black over-ear headphones on a dark background",
  },
]

const styles = StyleSheet.create({
  content: { gap: 22, paddingTop: 40, paddingBottom: 20 },
  title: { paddingHorizontal: 20 },
  heading: { ...fontSize["3xl"], fontWeight: "600" },
  carousel: { gap: 8 },
  track: { marginStart: 0 },
  trackContent: {
    gap: 16,
    paddingHorizontal: 20,
    paddingTop: 8,
    paddingBottom: 24,
  },
  item: { paddingStart: 0 },
  card: { height: 440, borderRadius: 18 },
  text: { gap: 4, paddingHorizontal: 28, paddingTop: 28 },
  kicker: { ...fontSize.xs, fontWeight: "600", textTransform: "uppercase" },
  name: { ...fontSize["3xl"], fontWeight: "600" },
  tagline: { ...fontSize.lg },
  price: { ...fontSize.sm, marginTop: 6 },
  image: { margin: 20, minHeight: 0, flex: 1, borderRadius: radius.xl },
  indicator: { alignSelf: "center" },
})

function CarouselPageIndicator() {
  const { scrollSnaps, selectedIndex, scrollTo } = useCarousel()
  return (
    <PageIndicator
      accessibilityLabel="Latest products"
      count={scrollSnaps.length}
      index={selectedIndex}
      onIndexChange={(index) => scrollTo(index)}
      style={styles.indicator}
    />
  )
}

function GridCarousel({
  onSelectProduct,
}: {
  onSelectProduct?: (name: string) => void
}) {
  const { colors } = useTheme()
  return (
    <Screen variant="secondary">
      <ScrollView contentContainerStyle={styles.content}>
        <AnimatedView
          entering={FadeInDown.duration(600).reduceMotion(ReduceMotion.System)}
          style={styles.title}
        >
          <Text role="heading" style={styles.heading}>
            The latest.{" "}
            <Text style={[styles.heading, { color: colors.mutedForeground }]}>
              Fresh favorites of the season.
            </Text>
          </Text>
        </AnimatedView>
        <Carousel
          opts={{ align: "center" }}
          accessibilityLabel="Latest products"
          style={styles.carousel}
        >
          <CarouselContent
            style={styles.track}
            contentContainerStyle={styles.trackContent}
          >
            {PRODUCTS.map((product) => (
              <CarouselItem key={product.name} basis={0.86} style={styles.item}>
                <Pressable
                  role="link"
                  accessibilityLabel={`${product.kicker ? `${product.kicker}, ` : ""}${product.name}. ${product.tagline} From ${product.price}`}
                  onPress={() => onSelectProduct?.(product.name)}
                  style={({ pressed }) => [
                    styles.card,
                    shadow.md,
                    { backgroundColor: colors.background },
                    pressed && { opacity: 0.8 },
                  ]}
                >
                  <View style={styles.text}>
                    {product.kicker ? (
                      <Text
                        style={[styles.kicker, { color: palette.orange500 }]}
                      >
                        {product.kicker}
                      </Text>
                    ) : null}
                    <Text style={styles.name}>{product.name}</Text>
                    <Text style={styles.tagline}>{product.tagline}</Text>
                    <Text
                      style={[styles.price, { color: colors.mutedForeground }]}
                    >
                      From {product.price}
                    </Text>
                  </View>
                  <Image
                    source={{ uri: product.image }}
                    resizeMode="cover"
                    accessibilityIgnoresInvertColors
                    accessibilityLabel={product.alt}
                    style={[
                      styles.image,
                      { backgroundColor: colors.secondary },
                    ]}
                  />
                </Pressable>
              </CarouselItem>
            ))}
          </CarouselContent>
          <CarouselPageIndicator />
        </Carousel>
      </ScrollView>
    </Screen>
  )
}

export { GridCarousel }
```

5. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

```swift title="Package.swift"
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
```

3. Copy the theme files every view uses:

- `swift/Theme/Theme.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Theme.swift)

- `swift/Theme/Tokens.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Tokens.swift)

4. Copy the source and every file it uses:

- `swift/Blocks/GridCarousel.swift`: shown above

- `swift/Components/Button.swift`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `swift/Components/ButtonGroup.swift`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `swift/Components/Carousel.swift`: see [Carousel](https://ui.suiss.app/docs/components/carousel.md)

- `swift/Components/Haptics.swift`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `swift/Components/KeyboardScrollView.swift`: see [Keyboard Scroll View](https://ui.suiss.app/docs/components/keyboard-scroll-view.md)

- `swift/Components/PageIndicator.swift`: see [Page Indicator](https://ui.suiss.app/docs/components/page-indicator.md)

- `swift/Components/Screen.swift`: see [Screen](https://ui.suiss.app/docs/components/screen.md)

- `swift/Components/TextVariant.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/TextVariant.swift)

5. Add the files to your app target.

**Jetpack Compose**

1. Set `minSdk` to 24 or higher and add the dependencies:

```kotlin title="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")
```

2. Copy the theme files every view uses:

- `kotlin/theme/Theme.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Theme.kt)

- `kotlin/theme/Tokens.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Tokens.kt)

3. Copy the source and every file it uses:

- `kotlin/blocks/GridCarousel.kt`: shown above

- `kotlin/components/Button.kt`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `kotlin/components/ButtonGroup.kt`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `kotlin/components/Carousel.kt`: see [Carousel](https://ui.suiss.app/docs/components/carousel.md)

- `kotlin/components/Content.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Content.kt)

- `kotlin/components/Haptics.kt`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `kotlin/components/Icon.kt`: see [Icon](https://ui.suiss.app/docs/components/icon.md)

- `kotlin/components/PageIndicator.kt`: see [Page Indicator](https://ui.suiss.app/docs/components/page-indicator.md)

- `kotlin/components/Screen.kt`: see [Screen](https://ui.suiss.app/docs/components/screen.md)

4. Update the `com.esuiss.ui` package names to match your project.

## Usage

```tsx
import { GridCarousel } from "@/components/blocks/grid-carousel"
```

```tsx
<GridCarousel />
```

Replace `PRODUCTS` with your items. Pass `onSelectProduct` to receive the name of the pressed product.

