# Nav Categories

A store landing header with a draggable carousel of category tiles and a heading that follows the selection.

Nav Categories 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/nav-categories

**React**

**Example**

```tsx title="components/blocks/nav-categories.tsx"
"use client"

import * as React from "react"

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

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

const categories = [
  { label: "Laptop", image: photo("1785245560368-f6d715956e5d") },
  { label: "Phone", image: photo("1714578187196-29775454aa39") },
  { label: "Pad", image: photo("1607452263110-39a87c399c50") },
  { label: "Watch", image: photo("1660844817855-3ecc7ef21f12") },
  { label: "Pods", image: photo("1599669454699-248893623440") },
  { label: "Cases", image: photo("1703676311066-34caf2cbee33") },
  { label: "Camera", image: photo("1604677209244-6569d050557a") },
  { label: "Colors", image: photo("1633596683562-4a47eb4983c5") },
  { label: "Pro", image: photo("1760978631841-f3754859ca59") },
  { label: "Accessories", image: photo("1645323927877-3de25b4f819c") },
]

export function NavCategories() {
  const [selected, setSelected] = React.useState("Phone")

  return (
    <section className="flex flex-col gap-7 bg-background pt-11 pb-10">
      <div className="flex flex-wrap items-end gap-6 px-6 md:px-12">
        <div className="flex min-w-60 flex-1 flex-col gap-1">
          <h1 className="text-5xl font-semibold tracking-tight">Store.</h1>
          <p className="text-2xl text-balance text-muted-foreground">
            The best way to buy the suiss products you love.
          </p>
        </div>
        <div className="flex flex-col items-start gap-1.5 text-xs">
          <span className="font-semibold text-muted-foreground">
            Need shopping help?
          </span>
          <a href="#specialist" className="text-primary hover:underline">
            Ask a Specialist ›
          </a>
          <a href="#stores" className="text-primary hover:underline">
            Visit a suiss Store near you ›
          </a>
        </div>
      </div>
      <Carousel
        opts={{ align: "start", dragFree: true }}
        aria-label="Categories"
        className="flex flex-col gap-3"
      >
        <CarouselContent className="ms-0 gap-5.5 px-6 py-2 md:px-12">
          {categories.map((category) => {
            const pressed = selected === category.label
            return (
              <CarouselItem key={category.label} className="basis-auto ps-0">
                <Toggle
                  pressed={pressed}
                  onPressedChange={() => setSelected(category.label)}
                  className="group/category h-auto flex-col gap-2.5 rounded-xl p-0 hover:bg-transparent aria-pressed:bg-transparent aria-pressed:hover:bg-transparent"
                >
                  <img
                    src={category.image}
                    alt=""
                    className="h-19.5 w-30 rounded-[0.875rem] bg-secondary object-cover transition-[translate,box-shadow] duration-350 ease-[cubic-bezier(0.3,1.25,0.5,1)] group-aria-pressed/category:-translate-y-0.5 group-aria-pressed/category:ring-2 group-aria-pressed/category:ring-primary"
                  />
                  <span className="text-sm font-semibold text-foreground group-aria-pressed/category:text-primary">
                    {category.label}
                  </span>
                </Toggle>
              </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>
      <div className="mx-6 border-t border-border pt-5.5 md:mx-12">
        <p className="text-2xl font-semibold tracking-tight text-balance">
          The latest.{" "}
          <span className="text-muted-foreground">
            Take a look at what's new in {selected}.
          </span>
        </p>
      </div>
    </section>
  )
}
```

A store landing header with a Store heading and tagline beside quick help links, then a draggable row of image tiles for each category. Selecting a tile lifts it and updates the line below to name the category. Use it as the top of a shop home page or any visual category picker.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/nav-categories
```

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 nav-categories
```

### Manual

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

2. Copy the source into `components/blocks/nav-categories.tsx`:

```tsx title="components/blocks/nav-categories.tsx"
"use client"

import * as React from "react"

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

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

const categories = [
  { label: "Laptop", image: photo("1785245560368-f6d715956e5d") },
  { label: "Phone", image: photo("1714578187196-29775454aa39") },
  { label: "Pad", image: photo("1607452263110-39a87c399c50") },
  { label: "Watch", image: photo("1660844817855-3ecc7ef21f12") },
  { label: "Pods", image: photo("1599669454699-248893623440") },
  { label: "Cases", image: photo("1703676311066-34caf2cbee33") },
  { label: "Camera", image: photo("1604677209244-6569d050557a") },
  { label: "Colors", image: photo("1633596683562-4a47eb4983c5") },
  { label: "Pro", image: photo("1760978631841-f3754859ca59") },
  { label: "Accessories", image: photo("1645323927877-3de25b4f819c") },
]

export function NavCategories() {
  const [selected, setSelected] = React.useState("Phone")

  return (
    <section className="flex flex-col gap-7 bg-background pt-11 pb-10">
      <div className="flex flex-wrap items-end gap-6 px-6 md:px-12">
        <div className="flex min-w-60 flex-1 flex-col gap-1">
          <h1 className="text-5xl font-semibold tracking-tight">Store.</h1>
          <p className="text-2xl text-balance text-muted-foreground">
            The best way to buy the suiss products you love.
          </p>
        </div>
        <div className="flex flex-col items-start gap-1.5 text-xs">
          <span className="font-semibold text-muted-foreground">
            Need shopping help?
          </span>
          <a href="#specialist" className="text-primary hover:underline">
            Ask a Specialist ›
          </a>
          <a href="#stores" className="text-primary hover:underline">
            Visit a suiss Store near you ›
          </a>
        </div>
      </div>
      <Carousel
        opts={{ align: "start", dragFree: true }}
        aria-label="Categories"
        className="flex flex-col gap-3"
      >
        <CarouselContent className="ms-0 gap-5.5 px-6 py-2 md:px-12">
          {categories.map((category) => {
            const pressed = selected === category.label
            return (
              <CarouselItem key={category.label} className="basis-auto ps-0">
                <Toggle
                  pressed={pressed}
                  onPressedChange={() => setSelected(category.label)}
                  className="group/category h-auto flex-col gap-2.5 rounded-xl p-0 hover:bg-transparent aria-pressed:bg-transparent aria-pressed:hover:bg-transparent"
                >
                  <img
                    src={category.image}
                    alt=""
                    className="h-19.5 w-30 rounded-[0.875rem] bg-secondary object-cover transition-[translate,box-shadow] duration-350 ease-[cubic-bezier(0.3,1.25,0.5,1)] group-aria-pressed/category:-translate-y-0.5 group-aria-pressed/category:ring-2 group-aria-pressed/category:ring-primary"
                  />
                  <span className="text-sm font-semibold text-foreground group-aria-pressed/category:text-primary">
                    {category.label}
                  </span>
                </Toggle>
              </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>
      <div className="mx-6 border-t border-border pt-5.5 md:mx-12">
        <p className="text-2xl font-semibold tracking-tight text-balance">
          The latest.{" "}
          <span className="text-muted-foreground">
            Take a look at what's new in {selected}.
          </span>
        </p>
      </div>
    </section>
  )
}
```

3. Update the import paths to match your project.

## Usage

```tsx
import { NavCategories } from "@/components/blocks/nav-categories"
```

```tsx
<NavCategories />
```

Replace `categories` with your categories, each with a label and image. Tiles only select themselves, so navigate or load products from the `Toggle`'s `onPressedChange`. Point the `#specialist` and `#stores` links at your routes.

## Notes

Tile images are decorative, so each tile is named by its label. Images are Unsplash placeholders.

**React Native, SwiftUI, Jetpack Compose**

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

```tsx title="uniwind/components/blocks/nav-categories.tsx"
import * as React from "react"
import { Image, Pressable, ScrollView, View } from "react-native"
import { FadeIn, FadeInDown, ReduceMotion } from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { Chip, ChipGroup } from "@/components/ui/chip"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"

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

const CATEGORIES = [
  { label: "Laptop", image: photo("1785245560368-f6d715956e5d") },
  { label: "Phone", image: photo("1714578187196-29775454aa39") },
  { label: "Pad", image: photo("1607452263110-39a87c399c50") },
  { label: "Watch", image: photo("1660844817855-3ecc7ef21f12") },
  { label: "Pods", image: photo("1599669454699-248893623440") },
  { label: "Cases", image: photo("1703676311066-34caf2cbee33") },
  { label: "Camera", image: photo("1604677209244-6569d050557a") },
  { label: "Colors", image: photo("1633596683562-4a47eb4983c5") },
  { label: "Pro", image: photo("1760978631841-f3754859ca59") },
  { label: "Accessories", image: photo("1645323927877-3de25b4f819c") },
]

function NavCategories({
  onSelectCategory,
  onAskSpecialist,
  onFindStore,
}: {
  onSelectCategory?: (label: string) => void
  onAskSpecialist?: () => void
  onFindStore?: () => void
}) {
  const [selected, setSelected] = React.useState("Phone")

  return (
    <Screen>
      <ScrollView contentContainerClassName="gap-7 pt-11 pb-10">
        <AnimatedView
          entering={FadeInDown.duration(600).reduceMotion(ReduceMotion.System)}
          className="gap-6 px-5"
        >
          <View className="gap-1">
            <Text role="heading" className="text-3xl font-bold">
              Store.
            </Text>
            <Text className="text-xl text-muted-foreground">
              The best way to buy the suiss products you love.
            </Text>
          </View>
          <View className="items-start gap-1.5">
            <Text className="text-xs font-semibold text-muted-foreground">
              Need shopping help?
            </Text>
            <Pressable
              role="link"
              hitSlop={6}
              onPress={onAskSpecialist}
              className="active:opacity-60"
            >
              <Text className="text-xs text-primary">Ask a Specialist ›</Text>
            </Pressable>
            <Pressable
              role="link"
              hitSlop={6}
              onPress={onFindStore}
              className="active:opacity-60"
            >
              <Text className="text-xs text-primary">
                Visit a suiss Store near you ›
              </Text>
            </Pressable>
          </View>
        </AnimatedView>
        <ChipGroup
          scrollable
          value={[selected]}
          onValueChange={(value) => {
            if (!value[0]) return
            setSelected(value[0])
            onSelectCategory?.(value[0])
          }}
          accessibilityLabel="Categories"
          className="px-5"
        >
          {CATEGORIES.map((category) => (
            <Chip
              key={category.label}
              value={category.label}
              accessibilityLabel={category.label}
              className="h-10 gap-2 ps-1 pe-3.5"
            >
              <Image
                source={{ uri: category.image }}
                resizeMode="cover"
                accessibilityIgnoresInvertColors
                className="size-8 rounded-full bg-muted"
              />
              <Text numberOfLines={1}>{category.label}</Text>
            </Chip>
          ))}
        </ChipGroup>
        <View className="mx-5 border-t border-border pt-5.5">
          <AnimatedView
            key={selected}
            entering={FadeIn.duration(400).reduceMotion(ReduceMotion.System)}
            accessibilityLiveRegion="polite"
          >
            <Text className="text-2xl font-semibold">
              The latest.{" "}
              <Text className="text-2xl font-semibold text-muted-foreground">
                Take a look at what's new in {selected}.
              </Text>
            </Text>
          </AnimatedView>
        </View>
      </ScrollView>
    </Screen>
  )
}

export { NavCategories }
```

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

public struct NavCategories: View {
  struct Category: Identifiable {
    let label: String
    let image: URL?

    var id: String { label }
  }

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

  static var categories: [Category] {
    [
      Category(label: "Laptop", image: photo("1785245560368-f6d715956e5d")),
      Category(label: "Phone", image: photo("1714578187196-29775454aa39")),
      Category(label: "Pad", image: photo("1607452263110-39a87c399c50")),
      Category(label: "Watch", image: photo("1660844817855-3ecc7ef21f12")),
      Category(label: "Pods", image: photo("1599669454699-248893623440")),
      Category(label: "Cases", image: photo("1703676311066-34caf2cbee33")),
      Category(label: "Camera", image: photo("1604677209244-6569d050557a")),
      Category(label: "Colors", image: photo("1633596683562-4a47eb4983c5")),
      Category(label: "Pro", image: photo("1760978631841-f3754859ca59")),
      Category(label: "Accessories", image: photo("1645323927877-3de25b4f819c")),
    ]
  }

  private var onSelectCategory: ((String) -> Void)?
  private var onAskSpecialist: (() -> Void)?
  private var onFindStore: (() -> Void)?
  @State private var selected: String?
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init(
    onSelectCategory: ((_ category: String) -> Void)? = nil,
    onAskSpecialist: (() -> Void)? = nil,
    onFindStore: (() -> Void)? = nil
  ) {
    self.init(selected: "Phone")
    self.onSelectCategory = onSelectCategory
    self.onAskSpecialist = onAskSpecialist
    self.onFindStore = onFindStore
  }

  init(selected: String) {
    _selected = State(initialValue: selected)
  }

  public var body: some View {
    let current = Self.categories.first { $0.label == selected } ?? Self.categories[1]
    Screen {
      HeaderScrollView {
        HeaderLargeTitle("Store.")
        VStack(alignment: .leading, spacing: 28) {
          VStack(alignment: .leading, spacing: 20) {
            Text("The best way to buy the suiss products you love.")
              .scaledTextStyle(.xl)
              .foregroundStyle(EsuissColor.mutedForeground)
              .fixedSize(horizontal: false, vertical: true)
            VStack(alignment: .leading, spacing: 0) {
              Text("Need shopping help?")
                .scaledFont(.xs, weight: .semibold)
                .foregroundStyle(EsuissColor.mutedForeground)
              Button("Ask a Specialist ›") { onAskSpecialist?() }
                .buttonStyle(NavCategoriesLinkStyle())
              Button("Visit a suiss Store near you ›") { onFindStore?() }
                .buttonStyle(NavCategoriesLinkStyle())
            }
          }
          .padding(.horizontal, 16)
          ChipGroup(selection: selection, scrollable: true) {
            ForEach(Self.categories) { category in
              Chip(LocalizedStringKey(category.label), value: category.label)
            }
          }
          .contentMargins(.horizontal, 16, for: .scrollContent)
          .accessibilityLabel(Text("Categories"))
          VStack(alignment: .leading, spacing: 18) {
            Text("\(Text("The latest.")) \(Text("Take a look at what's new in \(Text(LocalizedStringKey(current.label))).").foregroundStyle(EsuissColor.mutedForeground))")
              .scaledTextStyle(.xl, weight: .semibold)
              .fixedSize(horizontal: false, vertical: true)
              .contentTransition(.opacity)
            EsuissColor.secondary
              .overlay {
                ForEach(Self.categories) { category in
                  Color.clear.overlay {
                    AsyncImage(url: category.image) { phase in
                      if let image = phase.image {
                        image.resizable().scaledToFill()
                      }
                    }
                  }
                  .opacity(category.label == current.label ? 1 : 0)
                }
              }
              .aspectRatio(4 / 3, contentMode: .fit)
              .clipShape(RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous))
              .accessibilityHidden(true)
          }
          .padding(.top, 22)
          .overlay(alignment: .top) {
            EsuissColor.border.frame(height: 1)
          }
          .padding(.horizontal, 16)
        }
        .padding(.top, 4)
        .padding(.bottom, 32)
      } header: {
        Header {
          HeaderTitle("Store")
        }
      }
    }
    .animation(reduceMotion ? nil : .easeOut(duration: 0.3), value: selected)
  }

  private var selection: Binding<String?> {
    Binding(
      get: { selected },
      set: { value in
        guard let value else { return }
        selected = value
        onSelectCategory?(value)
      }
    )
  }
}

private struct NavCategoriesLinkStyle: ButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    configuration.label
      .scaledFont(.sm)
      .foregroundStyle(EsuissColor.primary)
      .opacity(configuration.isPressed ? 0.8 : 1)
      .frame(minHeight: 32)
      .contentShape(Rectangle())
  }
}
```

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

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.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.text.BasicText
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.liveRegion
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 com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.Camera
import com.adamglin.phosphoricons.regular.DeviceMobile
import com.adamglin.phosphoricons.regular.DeviceMobileCamera
import com.adamglin.phosphoricons.regular.DeviceTablet
import com.adamglin.phosphoricons.regular.Headphones
import com.adamglin.phosphoricons.regular.Laptop
import com.adamglin.phosphoricons.regular.Palette
import com.adamglin.phosphoricons.regular.Plug
import com.adamglin.phosphoricons.regular.Sparkle
import com.adamglin.phosphoricons.regular.Watch
import com.esuiss.ui.components.Chip
import com.esuiss.ui.components.ChipGroup
import com.esuiss.ui.components.Screen
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

private class NavCategory(val label: String, val icon: ImageVector)

private val NavCategoryList = listOf(
    NavCategory("Laptop", PhosphorIcons.Regular.Laptop),
    NavCategory("Phone", PhosphorIcons.Regular.DeviceMobile),
    NavCategory("Pad", PhosphorIcons.Regular.DeviceTablet),
    NavCategory("Watch", PhosphorIcons.Regular.Watch),
    NavCategory("Pods", PhosphorIcons.Regular.Headphones),
    NavCategory("Cases", PhosphorIcons.Regular.DeviceMobileCamera),
    NavCategory("Camera", PhosphorIcons.Regular.Camera),
    NavCategory("Colors", PhosphorIcons.Regular.Palette),
    NavCategory("Pro", PhosphorIcons.Regular.Sparkle),
    NavCategory("Accessories", PhosphorIcons.Regular.Plug),
)

@Composable
fun NavCategories(
    modifier: Modifier = Modifier,
    onSelectCategory: (category: String) -> Unit = {},
    onAskSpecialist: () -> Unit = {},
    onFindStore: () -> Unit = {},
) {
    val colors = EsuissTheme.colors
    var selected by rememberSaveable { mutableStateOf("Phone") }

    Screen(modifier) {
        Column(
            Modifier
                .fillMaxSize()
                .verticalScroll(rememberScrollState())
                .padding(top = 44.dp, bottom = 40.dp),
            verticalArrangement = Arrangement.spacedBy(28.dp),
        ) {
            Column(
                Modifier.padding(horizontal = 24.dp),
                verticalArrangement = Arrangement.spacedBy(20.dp),
            ) {
                Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
                    BasicText(
                        "Store.",
                        modifier = Modifier.semantics { heading() },
                        style = Typography.xl3.copy(color = colors.foreground, fontWeight = FontWeight.Bold),
                    )
                    BasicText(
                        "The best way to buy the suiss products you love.",
                        style = Typography.xl.copy(color = colors.mutedForeground),
                    )
                }
                Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
                    BasicText("Need shopping help?", style = Typography.xs.copy(color = colors.mutedForeground, fontWeight = FontWeight.SemiBold))
                    NavLink("Ask a Specialist ›", onAskSpecialist)
                    NavLink("Visit a suiss Store near you ›", onFindStore)
                }
            }
            ChipGroup(
                value = selected,
                onValueChange = { value ->
                    if (value != null && value != selected) {
                        selected = value
                        onSelectCategory(value)
                    }
                },
                modifier = Modifier
                    .fillMaxWidth()
                    .semantics { contentDescription = "Categories" },
                scrollable = true,
                contentPadding = PaddingValues(horizontal = 24.dp, vertical = 4.dp),
            ) {
                NavCategoryList.forEach { category ->
                    Chip(category.label, category.label, icon = category.icon)
                }
            }
            Column(Modifier.padding(horizontal = 24.dp)) {
                Box(
                    Modifier
                        .fillMaxWidth()
                        .height(1.dp)
                        .background(colors.border),
                )
                BasicText(
                    buildAnnotatedString {
                        append("The latest. ")
                        withStyle(SpanStyle(color = colors.mutedForeground)) { append("Take a look at what's new in $selected.") }
                    },
                    modifier = Modifier
                        .padding(top = 22.dp)
                        .semantics { liveRegion = LiveRegionMode.Polite },
                    style = Typography.xl2.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold),
                )
            }
        }
    }
}

@Composable
private fun NavLink(text: String, onClick: () -> Unit) {
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    BasicText(
        text,
        modifier = Modifier
            .graphicsLayer { alpha = if (pressed) 0.8f else 1f }
            .clickable(interactionSource = interaction, indication = null, role = Role.Button, onClick = onClick)
            .padding(vertical = 4.dp),
        style = Typography.sm.copy(color = EsuissTheme.colors.primary),
    )
}
```

A store screen with a large heading, links to ask a specialist or find a store, and a scrollable `ChipGroup` of categories, each `Chip` showing a photo and a label. Selecting a category updates the line below it.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/nav-categories
```

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 nav-categories
```

**SwiftUI**

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

**Jetpack Compose**

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

The CLI also adds what it builds on: [Chip](https://ui.suiss.app/docs/components/chip), [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: [Chip](https://ui.suiss.app/docs/components/chip), [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/nav-categories.tsx`:

- `uniwind/components/blocks/nav-categories.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: [Chip](https://ui.suiss.app/docs/components/chip), [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/nav-categories.tsx`:

```tsx title="nativewind/components/blocks/nav-categories.tsx"
import * as React from "react"
import { Image, Pressable, ScrollView, View } from "react-native"
import { FadeIn, FadeInDown, ReduceMotion } from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { Chip, ChipGroup } from "@/components/ui/chip"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"

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

const CATEGORIES = [
  { label: "Laptop", image: photo("1785245560368-f6d715956e5d") },
  { label: "Phone", image: photo("1714578187196-29775454aa39") },
  { label: "Pad", image: photo("1607452263110-39a87c399c50") },
  { label: "Watch", image: photo("1660844817855-3ecc7ef21f12") },
  { label: "Pods", image: photo("1599669454699-248893623440") },
  { label: "Cases", image: photo("1703676311066-34caf2cbee33") },
  { label: "Camera", image: photo("1604677209244-6569d050557a") },
  { label: "Colors", image: photo("1633596683562-4a47eb4983c5") },
  { label: "Pro", image: photo("1760978631841-f3754859ca59") },
  { label: "Accessories", image: photo("1645323927877-3de25b4f819c") },
]

function NavCategories({
  onSelectCategory,
  onAskSpecialist,
  onFindStore,
}: {
  onSelectCategory?: (label: string) => void
  onAskSpecialist?: () => void
  onFindStore?: () => void
}) {
  const [selected, setSelected] = React.useState("Phone")

  return (
    <Screen>
      <ScrollView contentContainerClassName="gap-7 pt-11 pb-10">
        <AnimatedView
          entering={FadeInDown.duration(600).reduceMotion(ReduceMotion.System)}
          className="gap-6 px-5"
        >
          <View className="gap-1">
            <Text role="heading" className="text-3xl font-bold">
              Store.
            </Text>
            <Text className="text-xl text-muted-foreground">
              The best way to buy the suiss products you love.
            </Text>
          </View>
          <View className="items-start gap-1.5">
            <Text className="text-xs font-semibold text-muted-foreground">
              Need shopping help?
            </Text>
            <Pressable
              role="link"
              hitSlop={6}
              onPress={onAskSpecialist}
              className="active:opacity-60"
            >
              <Text className="text-xs text-primary">Ask a Specialist ›</Text>
            </Pressable>
            <Pressable
              role="link"
              hitSlop={6}
              onPress={onFindStore}
              className="active:opacity-60"
            >
              <Text className="text-xs text-primary">
                Visit a suiss Store near you ›
              </Text>
            </Pressable>
          </View>
        </AnimatedView>
        <ChipGroup
          scrollable
          value={[selected]}
          onValueChange={(value) => {
            if (!value[0]) return
            setSelected(value[0])
            onSelectCategory?.(value[0])
          }}
          accessibilityLabel="Categories"
          className="px-5"
        >
          {CATEGORIES.map((category) => (
            <Chip
              key={category.label}
              value={category.label}
              accessibilityLabel={category.label}
              className="h-10 gap-2 ps-1 pe-3.5"
            >
              <Image
                source={{ uri: category.image }}
                resizeMode="cover"
                accessibilityIgnoresInvertColors
                className="size-8 rounded-full bg-muted"
              />
              <Text numberOfLines={1}>{category.label}</Text>
            </Chip>
          ))}
        </ChipGroup>
        <View className="mx-5 border-t border-border pt-[1.375rem]">
          <AnimatedView
            key={selected}
            entering={FadeIn.duration(400).reduceMotion(ReduceMotion.System)}
            accessibilityLiveRegion="polite"
          >
            <Text className="text-2xl font-semibold">
              The latest.{" "}
              <Text className="text-2xl font-semibold text-muted-foreground">
                Take a look at what's new in {selected}.
              </Text>
            </Text>
          </AnimatedView>
        </View>
      </ScrollView>
    </Screen>
  )
}

export { NavCategories }
```

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: [Chip](https://ui.suiss.app/docs/components/chip), [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/nav-categories.tsx`:

```tsx title="stylesheet/components/blocks/nav-categories.tsx"
import * as React from "react"
import { Image, Pressable, ScrollView, StyleSheet, View } from "react-native"
import { FadeIn, FadeInDown, ReduceMotion } from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { Chip, ChipGroup } from "@/components/ui/chip"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"
import { fontSize, radius, useTheme } from "@/components/ui/theme"

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

const CATEGORIES = [
  { label: "Laptop", image: photo("1785245560368-f6d715956e5d") },
  { label: "Phone", image: photo("1714578187196-29775454aa39") },
  { label: "Pad", image: photo("1607452263110-39a87c399c50") },
  { label: "Watch", image: photo("1660844817855-3ecc7ef21f12") },
  { label: "Pods", image: photo("1599669454699-248893623440") },
  { label: "Cases", image: photo("1703676311066-34caf2cbee33") },
  { label: "Camera", image: photo("1604677209244-6569d050557a") },
  { label: "Colors", image: photo("1633596683562-4a47eb4983c5") },
  { label: "Pro", image: photo("1760978631841-f3754859ca59") },
  { label: "Accessories", image: photo("1645323927877-3de25b4f819c") },
]

const styles = StyleSheet.create({
  content: { gap: 28, paddingTop: 44, paddingBottom: 40 },
  intro: { gap: 24, paddingHorizontal: 20 },
  titles: { gap: 4 },
  title: { ...fontSize["3xl"], fontWeight: "700" },
  subtitle: { ...fontSize.xl },
  help: { alignItems: "flex-start", gap: 6 },
  helpTitle: { ...fontSize.xs, fontWeight: "600" },
  link: { ...fontSize.xs },
  chips: { paddingHorizontal: 20 },
  chip: { height: 40, gap: 8, paddingStart: 4, paddingEnd: 14 },
  image: { width: 32, height: 32, borderRadius: radius.full },
  latest: {
    marginHorizontal: 20,
    borderTopWidth: StyleSheet.hairlineWidth,
    paddingTop: 22,
  },
  latestText: { ...fontSize["2xl"], fontWeight: "600" },
})

function NavCategories({
  onSelectCategory,
  onAskSpecialist,
  onFindStore,
}: {
  onSelectCategory?: (label: string) => void
  onAskSpecialist?: () => void
  onFindStore?: () => void
}) {
  const { colors } = useTheme()
  const [selected, setSelected] = React.useState("Phone")

  return (
    <Screen>
      <ScrollView contentContainerStyle={styles.content}>
        <AnimatedView
          entering={FadeInDown.duration(600).reduceMotion(ReduceMotion.System)}
          style={styles.intro}
        >
          <View style={styles.titles}>
            <Text role="heading" style={styles.title}>
              Store.
            </Text>
            <Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
              The best way to buy the suiss products you love.
            </Text>
          </View>
          <View style={styles.help}>
            <Text style={[styles.helpTitle, { color: colors.mutedForeground }]}>
              Need shopping help?
            </Text>
            <Pressable
              role="link"
              hitSlop={6}
              onPress={onAskSpecialist}
              style={({ pressed }) => pressed && { opacity: 0.6 }}
            >
              <Text style={[styles.link, { color: colors.primary }]}>
                Ask a Specialist ›
              </Text>
            </Pressable>
            <Pressable
              role="link"
              hitSlop={6}
              onPress={onFindStore}
              style={({ pressed }) => pressed && { opacity: 0.6 }}
            >
              <Text style={[styles.link, { color: colors.primary }]}>
                Visit a suiss Store near you ›
              </Text>
            </Pressable>
          </View>
        </AnimatedView>
        <ChipGroup
          scrollable
          value={[selected]}
          onValueChange={(value) => {
            if (!value[0]) return
            setSelected(value[0])
            onSelectCategory?.(value[0])
          }}
          accessibilityLabel="Categories"
          style={styles.chips}
        >
          {CATEGORIES.map((category) => (
            <Chip
              key={category.label}
              value={category.label}
              accessibilityLabel={category.label}
              style={styles.chip}
            >
              <Image
                source={{ uri: category.image }}
                resizeMode="cover"
                accessibilityIgnoresInvertColors
                style={[styles.image, { backgroundColor: colors.muted }]}
              />
              <Text numberOfLines={1}>{category.label}</Text>
            </Chip>
          ))}
        </ChipGroup>
        <View style={[styles.latest, { borderTopColor: colors.border }]}>
          <AnimatedView
            key={selected}
            entering={FadeIn.duration(400).reduceMotion(ReduceMotion.System)}
            accessibilityLiveRegion="polite"
          >
            <Text style={styles.latestText}>
              The latest.{" "}
              <Text
                style={[styles.latestText, { color: colors.mutedForeground }]}
              >
                Take a look at what's new in {selected}.
              </Text>
            </Text>
          </AnimatedView>
        </View>
      </ScrollView>
    </Screen>
  )
}

export { NavCategories }
```

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/NavCategories.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/Checkbox.swift`: see [Checkbox](https://ui.suiss.app/docs/components/checkbox.md)

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

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

- `swift/Components/DropdownMenu.swift`: see [Dropdown Menu](https://ui.suiss.app/docs/components/dropdown-menu.md)

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

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

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

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

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

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

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

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

- `swift/Components/Separator.swift`: see [Separator](https://ui.suiss.app/docs/components/separator.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/NavCategories.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/Checkbox.kt`: see [Checkbox](https://ui.suiss.app/docs/components/checkbox.md)

- `kotlin/components/Chip.kt`: see [Chip](https://ui.suiss.app/docs/components/chip.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/Field.kt`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `kotlin/components/FormSupport.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/FormSupport.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/Label.kt`: see [Label](https://ui.suiss.app/docs/components/label.md)

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

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

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

## Usage

```tsx
import { NavCategories } from "@/components/blocks/nav-categories"
```

```tsx
<NavCategories />
```

Replace `CATEGORIES` with your categories and images. Wire `onSelectCategory`, `onAskSpecialist` and `onFindStore` to your navigation. The photos are Unsplash placeholders.

