# Chip

A compact, selectable pill for filters and tags, with an optional icon and remove button.

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

Source: https://ui.suiss.app/docs/components/chip

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/components-chip--default.light.webp)

```tsx
import { Chip } from "@/components/ui/chip"

export function ChipExample() {
  return (
    <Chip>Open now</Chip>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct ChipExample: View {
  var body: some View {
    ChipToggleDemo(title: "Open now", initial: false)
  }
}

struct ChipToggleDemo: View {
  let title: LocalizedStringKey
  var size: ChipSize?
  var icon: Image?
  var onRemove: (() -> Void)?

  @State private var selected: Bool

  init(
    title: LocalizedStringKey,
    initial: Bool,
    size: ChipSize? = nil,
    icon: Image? = nil,
    onRemove: (() -> Void)? = nil
  ) {
    self.title = title
    self.size = size
    self.icon = icon
    self.onRemove = onRemove
    _selected = State(initialValue: initial)
  }

  var body: some View {
    Chip(title, isSelected: $selected, size: size, icon: icon, onRemove: onRemove)
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.vector.ImageVector
import com.esuiss.ui.components.Chip
import com.esuiss.ui.components.ChipSize

@Composable
fun ChipItem(
    text: String,
    defaultSelected: Boolean = false,
    size: ChipSize = ChipSize.Default,
    enabled: Boolean = true,
    icon: ImageVector? = null,
    onRemove: (() -> Unit)? = null,
) {
    var selected by rememberSaveable { mutableStateOf(defaultSelected) }
    Chip(
        text,
        selected = selected,
        onSelectedChange = { selected = it },
        size = size,
        enabled = enabled,
        icon = icon,
        onRemove = onRemove,
    )
}

@Composable
fun ChipDefault() {
    Column {
        ChipItem("Open now")
    }
}
```

`Chip` is a toggle rendered as a rounded `Pressable`. It switches between an unselected `bg-secondary` background and a selected `bg-foreground` fill, and shrinks slightly while pressed. It has `default` and `sm` sizes, an optional leading `icon`, and an optional trailing remove button when `onRemove` is set.

`ChipGroup` manages selection for a set of chips: single selection by default, or several with `multiple`. It wraps chips onto new lines, or lays them out in a horizontal scroll view with `scrollable`.

## Installation

**React Native**

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

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 chip
```

**SwiftUI**

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

**Jetpack Compose**

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

The CLI also adds what it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text).

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install class-variance-authority cn phosphor-react-native react-native-svg
```

2. Add the pieces it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/toggle.tsx`, `components/primitives/toggle-group.tsx`, `components/primitives/use-render.tsx`.

3. Copy the source into `components/ui/chip.tsx`:

```tsx title="uniwind/components/ui/chip.tsx"
import { cva } from "class-variance-authority"
import { cn } from "cn"
import { type Icon as PhosphorIcon, XIcon } from "phosphor-react-native"
import * as React from "react"
import { Pressable, ScrollView } from "react-native"
import {
  type ToggleChangeEventDetails,
  Toggle as TogglePrimitive,
  type ToggleState,
} from "@/components/primitives/toggle"
import { ToggleGroup as ToggleGroupPrimitive } from "@/components/primitives/toggle-group"
import { resolveClassName } from "@/components/primitives/use-render"
import { Icon, IconClassContext } from "@/components/ui/icon"
import { Text, TextClassContext } from "@/components/ui/text"

type ChipSize = "default" | "sm"

const ChipGroupContext = React.createContext<{ size: ChipSize } | null>(null)

const chipVariants = cva(
  "shrink-0 flex-row items-center justify-center self-start rounded-full",
  {
    variants: {
      size: {
        default: "h-8 gap-1.5 px-3",
        sm: "h-7 gap-1 px-2.5",
      },
    },
    defaultVariants: {
      size: "default",
    },
  }
)

function chipStateClassName(state: ToggleState) {
  return cn(
    state.pressed ? "bg-foreground" : "bg-secondary",
    state.disabled ? "opacity-50" : "active:scale-96 active:opacity-80"
  )
}

function chipTextClassName(size: ChipSize, state: ToggleState) {
  return cn(
    "font-medium",
    size === "sm" ? "text-xs" : "text-sm",
    state.pressed ? "text-background" : "text-foreground"
  )
}

type ChipProps = Omit<
  React.ComponentProps<typeof TogglePrimitive>,
  "pressed" | "defaultPressed" | "onPressedChange"
> & {
  selected?: boolean
  defaultSelected?: boolean
  onSelectedChange?: (
    selected: boolean,
    eventDetails: ToggleChangeEventDetails
  ) => void
  size?: ChipSize
  icon?: PhosphorIcon
  onRemove?: () => void
  removeLabel?: string
}

function Chip({
  selected,
  defaultSelected,
  onSelectedChange,
  size: sizeProp,
  icon,
  onRemove,
  removeLabel = "Remove",
  className,
  children,
  disabled,
  ...props
}: ChipProps) {
  const group = React.useContext(ChipGroupContext)
  const size = sizeProp ?? group?.size ?? "default"
  return (
    <TogglePrimitive
      pressed={selected}
      defaultPressed={defaultSelected}
      onPressedChange={onSelectedChange}
      disabled={disabled}
      accessibilityActions={
        onRemove && !disabled
          ? [{ name: "remove", label: removeLabel }]
          : undefined
      }
      onAccessibilityAction={(event) => {
        if (disabled) return
        if (event.nativeEvent.actionName === "remove") onRemove?.()
      }}
      className={(state) =>
        cn(
          chipVariants({ size }),
          chipStateClassName(state),
          onRemove && (size === "sm" ? "pe-1.5" : "pe-2"),
          resolveClassName(className, state)
        )
      }
      render={(renderProps, state) => (
        <TextClassContext.Provider value={chipTextClassName(size, state)}>
          <IconClassContext.Provider
            value={size === "sm" ? "size-3.5" : "size-4"}
          >
            <Pressable {...renderProps} />
          </IconClassContext.Provider>
        </TextClassContext.Provider>
      )}
      {...props}
    >
      {icon ? <Icon as={icon} /> : null}
      {typeof children === "string" || typeof children === "number" ? (
        <Text numberOfLines={1}>{children}</Text>
      ) : (
        children
      )}
      {onRemove ? (
        <Pressable
          role="button"
          accessibilityLabel={removeLabel}
          disabled={disabled}
          hitSlop={8}
          onPress={onRemove}
          className="size-5 items-center justify-center rounded-full active:opacity-80"
        >
          <Icon as={XIcon} weight="bold" className="size-3" />
        </Pressable>
      ) : null}
    </TogglePrimitive>
  )
}

function ChipGroup({
  size = "default",
  scrollable = false,
  className,
  ...props
}: React.ComponentProps<typeof ToggleGroupPrimitive> & {
  size?: ChipSize
  scrollable?: boolean
}) {
  const context = React.useMemo(() => ({ size }), [size])
  const group = (
    <ChipGroupContext.Provider value={context}>
      <ToggleGroupPrimitive
        className={(state) =>
          cn(
            "flex-row gap-2",
            !scrollable && "flex-wrap",
            resolveClassName(className, state)
          )
        }
        {...props}
      />
    </ChipGroupContext.Provider>
  )
  if (!scrollable) return group
  return (
    <ScrollView
      horizontal
      showsHorizontalScrollIndicator={false}
      keyboardShouldPersistTaps="handled"
      className="grow-0"
    >
      {group}
    </ScrollView>
  )
}

export type { ChipProps, ChipSize }
export { Chip, ChipGroup, chipVariants }
```

4. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install class-variance-authority cn phosphor-react-native react-native-svg
```

2. Add the pieces it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/toggle.tsx`, `components/primitives/toggle-group.tsx`, `components/primitives/use-render.tsx`.

3. Copy the source into `components/ui/chip.tsx`:

```tsx title="nativewind/components/ui/chip.tsx"
import { cva } from "class-variance-authority"
import { cn } from "cn"
import { type Icon as PhosphorIcon, XIcon } from "phosphor-react-native"
import * as React from "react"
import { Pressable, ScrollView } from "react-native"
import {
  type ToggleChangeEventDetails,
  Toggle as TogglePrimitive,
  type ToggleState,
} from "@/components/primitives/toggle"
import { ToggleGroup as ToggleGroupPrimitive } from "@/components/primitives/toggle-group"
import { resolveClassName } from "@/components/primitives/use-render"
import { Icon, IconClassContext } from "@/components/ui/icon"
import { Text, TextClassContext } from "@/components/ui/text"

type ChipSize = "default" | "sm"

const ChipGroupContext = React.createContext<{ size: ChipSize } | null>(null)

const chipVariants = cva(
  "shrink-0 flex-row items-center justify-center self-start rounded-full",
  {
    variants: {
      size: {
        default: "h-8 gap-1.5 px-3",
        sm: "h-7 gap-1 px-2.5",
      },
    },
    defaultVariants: {
      size: "default",
    },
  }
)

function chipStateClassName(state: ToggleState) {
  return cn(
    state.pressed ? "bg-foreground" : "bg-secondary",
    state.disabled ? "opacity-50" : "active:scale-[0.96] active:opacity-80"
  )
}

function chipTextClassName(size: ChipSize, state: ToggleState) {
  return cn(
    "font-medium",
    size === "sm" ? "text-xs" : "text-sm",
    state.pressed ? "text-background" : "text-foreground"
  )
}

type ChipProps = Omit<
  React.ComponentProps<typeof TogglePrimitive>,
  "pressed" | "defaultPressed" | "onPressedChange"
> & {
  selected?: boolean
  defaultSelected?: boolean
  onSelectedChange?: (
    selected: boolean,
    eventDetails: ToggleChangeEventDetails
  ) => void
  size?: ChipSize
  icon?: PhosphorIcon
  onRemove?: () => void
  removeLabel?: string
}

function Chip({
  selected,
  defaultSelected,
  onSelectedChange,
  size: sizeProp,
  icon,
  onRemove,
  removeLabel = "Remove",
  className,
  children,
  disabled,
  ...props
}: ChipProps) {
  const group = React.useContext(ChipGroupContext)
  const size = sizeProp ?? group?.size ?? "default"
  return (
    <TogglePrimitive
      pressed={selected}
      defaultPressed={defaultSelected}
      onPressedChange={onSelectedChange}
      disabled={disabled}
      accessibilityActions={
        onRemove && !disabled
          ? [{ name: "remove", label: removeLabel }]
          : undefined
      }
      onAccessibilityAction={(event) => {
        if (disabled) return
        if (event.nativeEvent.actionName === "remove") onRemove?.()
      }}
      className={(state) =>
        cn(
          chipVariants({ size }),
          chipStateClassName(state),
          onRemove && (size === "sm" ? "pe-1.5" : "pe-2"),
          resolveClassName(className, state)
        )
      }
      render={(renderProps, state) => (
        <TextClassContext.Provider value={chipTextClassName(size, state)}>
          <IconClassContext.Provider
            value={size === "sm" ? "size-3.5" : "size-4"}
          >
            <Pressable {...renderProps} />
          </IconClassContext.Provider>
        </TextClassContext.Provider>
      )}
      {...props}
    >
      {icon ? <Icon as={icon} /> : null}
      {typeof children === "string" || typeof children === "number" ? (
        <Text numberOfLines={1}>{children}</Text>
      ) : (
        children
      )}
      {onRemove ? (
        <Pressable
          role="button"
          accessibilityLabel={removeLabel}
          disabled={disabled}
          hitSlop={8}
          onPress={onRemove}
          className="size-5 items-center justify-center rounded-full active:opacity-80"
        >
          <Icon as={XIcon} weight="bold" className="size-3" />
        </Pressable>
      ) : null}
    </TogglePrimitive>
  )
}

function ChipGroup({
  size = "default",
  scrollable = false,
  className,
  ...props
}: React.ComponentProps<typeof ToggleGroupPrimitive> & {
  size?: ChipSize
  scrollable?: boolean
}) {
  const context = React.useMemo(() => ({ size }), [size])
  const group = (
    <ChipGroupContext.Provider value={context}>
      <ToggleGroupPrimitive
        className={(state) =>
          cn(
            "flex-row gap-2",
            !scrollable && "flex-wrap",
            resolveClassName(className, state)
          )
        }
        {...props}
      />
    </ChipGroupContext.Provider>
  )
  if (!scrollable) return group
  return (
    <ScrollView
      horizontal
      showsHorizontalScrollIndicator={false}
      keyboardShouldPersistTaps="handled"
      className="grow-0"
    >
      {group}
    </ScrollView>
  )
}

export type { ChipProps, ChipSize }
export { Chip, ChipGroup, chipVariants }
```

4. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

```bash title="Terminal"
npx expo install phosphor-react-native react-native-svg
```

2. Add the pieces it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/toggle.tsx`, `components/primitives/toggle-group.tsx`, `components/primitives/use-render.tsx`.

3. Copy the source into `components/ui/chip.tsx`:

```tsx title="stylesheet/components/ui/chip.tsx"
import { type Icon as PhosphorIcon, XIcon } from "phosphor-react-native"
import * as React from "react"
import {
  Pressable,
  ScrollView,
  type StyleProp,
  StyleSheet,
  type TextStyle,
  type ViewStyle,
} from "react-native"
import {
  type ToggleChangeEventDetails,
  Toggle as TogglePrimitive,
  type ToggleState,
} from "@/components/primitives/toggle"
import { ToggleGroup as ToggleGroupPrimitive } from "@/components/primitives/toggle-group"
import { resolveStyle } from "@/components/primitives/use-render"
import { Icon, IconSizeContext } from "@/components/ui/icon"
import { Text, TextStyleContext } from "@/components/ui/text"
import {
  fontSize,
  radius,
  type ThemeColors,
  useTheme,
} from "@/components/ui/theme"

type ChipSize = "default" | "sm"

const ChipGroupContext = React.createContext<{ size: ChipSize } | null>(null)

const sizes = StyleSheet.create({
  default: { height: 32, gap: 6, paddingHorizontal: 12 },
  sm: { height: 28, gap: 4, paddingHorizontal: 10 },
})

const styles = StyleSheet.create({
  root: {
    flexShrink: 0,
    flexDirection: "row",
    alignItems: "center",
    justifyContent: "center",
    alignSelf: "flex-start",
    borderRadius: radius.full,
  },
  removable: { paddingEnd: 8 },
  removableSm: { paddingEnd: 6 },
  scaled: { transform: [{ scale: 0.96 }] },
  remove: {
    width: 20,
    height: 20,
    alignItems: "center",
    justifyContent: "center",
    borderRadius: radius.full,
  },
  group: { flexDirection: "row", gap: 8 },
  wrap: { flexWrap: "wrap" },
  scroll: { flexGrow: 0 },
})

function chipStyle(
  size: ChipSize,
  colors: ThemeColors,
  state: ToggleState,
  pressing: boolean,
  removable: boolean
): StyleProp<ViewStyle> {
  return [
    styles.root,
    sizes[size],
    removable && (size === "sm" ? styles.removableSm : styles.removable),
    { backgroundColor: state.pressed ? colors.foreground : colors.secondary },
    pressing && !state.disabled && [styles.scaled, { opacity: 0.8 }],
    state.disabled && { opacity: 0.5 },
  ]
}

function chipTextStyle(
  size: ChipSize,
  colors: ThemeColors,
  state: ToggleState
): TextStyle {
  let color = colors.foreground
  if (state.pressed) color = colors.background
  return {
    ...(size === "sm" ? fontSize.xs : fontSize.sm),
    fontWeight: "500",
    color,
  }
}

type ChipProps = Omit<
  React.ComponentProps<typeof TogglePrimitive>,
  "pressed" | "defaultPressed" | "onPressedChange"
> & {
  selected?: boolean
  defaultSelected?: boolean
  onSelectedChange?: (
    selected: boolean,
    eventDetails: ToggleChangeEventDetails
  ) => void
  size?: ChipSize
  icon?: PhosphorIcon
  onRemove?: () => void
  removeLabel?: string
}

function Chip({
  selected,
  defaultSelected,
  onSelectedChange,
  size: sizeProp,
  icon,
  onRemove,
  removeLabel = "Remove",
  style,
  children,
  disabled,
  ...props
}: ChipProps) {
  const { colors } = useTheme()
  const group = React.useContext(ChipGroupContext)
  const size = sizeProp ?? group?.size ?? "default"
  return (
    <TogglePrimitive
      pressed={selected}
      defaultPressed={defaultSelected}
      onPressedChange={onSelectedChange}
      disabled={disabled}
      accessibilityActions={
        onRemove && !disabled
          ? [{ name: "remove", label: removeLabel }]
          : undefined
      }
      onAccessibilityAction={(event) => {
        if (disabled) return
        if (event.nativeEvent.actionName === "remove") onRemove?.()
      }}
      render={(renderProps, state) => (
        <TextStyleContext.Provider value={chipTextStyle(size, colors, state)}>
          <IconSizeContext.Provider value={size === "sm" ? 14 : 16}>
            <Pressable
              {...renderProps}
              style={({ pressed }) => [
                chipStyle(size, colors, state, pressed, Boolean(onRemove)),
                resolveStyle(style, state) as StyleProp<ViewStyle>,
              ]}
            />
          </IconSizeContext.Provider>
        </TextStyleContext.Provider>
      )}
      {...props}
    >
      {icon ? <Icon as={icon} /> : null}
      {typeof children === "string" || typeof children === "number" ? (
        <Text numberOfLines={1}>{children}</Text>
      ) : (
        children
      )}
      {onRemove ? (
        <Pressable
          role="button"
          accessibilityLabel={removeLabel}
          disabled={disabled}
          hitSlop={8}
          onPress={onRemove}
          style={({ pressed }) => [styles.remove, pressed && { opacity: 0.8 }]}
        >
          <Icon as={XIcon} weight="bold" size={12} />
        </Pressable>
      ) : null}
    </TogglePrimitive>
  )
}

function ChipGroup({
  size = "default",
  scrollable = false,
  style,
  ...props
}: React.ComponentProps<typeof ToggleGroupPrimitive> & {
  size?: ChipSize
  scrollable?: boolean
}) {
  const context = React.useMemo(() => ({ size }), [size])
  const group = (
    <ChipGroupContext.Provider value={context}>
      <ToggleGroupPrimitive
        style={(state) => [
          styles.group,
          !scrollable && styles.wrap,
          resolveStyle(style, state) as StyleProp<ViewStyle>,
        ]}
        {...props}
      />
    </ChipGroupContext.Provider>
  )
  if (!scrollable) return group
  return (
    <ScrollView
      horizontal
      showsHorizontalScrollIndicator={false}
      keyboardShouldPersistTaps="handled"
      style={styles.scroll}
    >
      {group}
    </ScrollView>
  )
}

export type { ChipProps, ChipSize }
export { Chip, ChipGroup, chipStyle, chipTextStyle }
```

4. 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 title="swift/Components/Chip.swift"
import PhosphorSwift
import SwiftUI

public enum ChipSize: CaseIterable, Sendable {
  case sm, `default`

  var height: CGFloat {
    switch self {
    case .sm: 28
    case .default: 32
    }
  }

  var padding: CGFloat {
    switch self {
    case .sm: 10
    case .default: 12
    }
  }

  var iconSize: CGFloat {
    switch self {
    case .sm: 14
    case .default: 16
    }
  }

  var textStyle: TextStyle {
    switch self {
    case .sm: .xs
    case .default: .sm
    }
  }
}

enum ChipSelection {
  static func toggle<Value: Hashable>(_ current: Value?, _ value: Value) -> Value? {
    current == value ? nil : value
  }

  static func toggle<Value: Hashable>(_ current: Set<Value>, _ value: Value) -> Set<Value> {
    var next = current
    if next.contains(value) {
      next.remove(value)
    } else {
      next.insert(value)
    }
    return next
  }
}

struct ChipGroupContext {
  var size: ChipSize
  var isSelected: (AnyHashable) -> Bool
  var toggle: (AnyHashable) -> Void
}

extension EnvironmentValues {
  @Entry var chipGroup: ChipGroupContext?
}

public struct ChipGroup<Value: Hashable, Content: View>: View {
  let size: ChipSize
  let scrollable: Bool
  let isSelected: (Value) -> Bool
  let toggle: (Value) -> Void
  let content: Content

  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init(
    selection: Binding<Value?>,
    size: ChipSize = .default,
    scrollable: Bool = false,
    @ViewBuilder content: () -> Content
  ) {
    self.size = size
    self.scrollable = scrollable
    isSelected = { selection.wrappedValue == $0 }
    toggle = { selection.wrappedValue = ChipSelection.toggle(selection.wrappedValue, $0) }
    self.content = content()
  }

  public init(
    selection: Binding<Set<Value>>,
    size: ChipSize = .default,
    scrollable: Bool = false,
    @ViewBuilder content: () -> Content
  ) {
    self.size = size
    self.scrollable = scrollable
    isSelected = { selection.wrappedValue.contains($0) }
    toggle = { selection.wrappedValue = ChipSelection.toggle(selection.wrappedValue, $0) }
    self.content = content()
  }

  public var body: some View {
    let context = ChipGroupContext(
      size: size,
      isSelected: { value in (value.base as? Value).map(isSelected) ?? false },
      toggle: { value in
        guard let value = value.base as? Value else { return }
        if reduceMotion {
          toggle(value)
        } else {
          withAnimation(.timingCurve(0.32, 0.72, 0, 1, duration: 0.2)) { toggle(value) }
        }
      }
    )
    Group {
      if scrollable {
        ScrollView(.horizontal, showsIndicators: false) {
          HStack(spacing: 8) { content }
        }
        .scrollClipDisabled()
      } else {
        ComboboxFlowLayout(spacing: 8) { content }
      }
    }
    .environment(\.chipGroup, context)
    .accessibilityElement(children: .contain)
  }
}

public struct Chip<Label: View>: View {
  enum Source {
    case none
    case binding(Binding<Bool>)
    case value(AnyHashable)
  }

  let source: Source
  let size: ChipSize?
  let icon: Image?
  let onRemove: (() -> Void)?
  let label: Label

  @Environment(\.chipGroup) private var group
  @Environment(\.isEnabled) private var isEnabled

  public init(
    isSelected: Binding<Bool>? = nil,
    size: ChipSize? = nil,
    icon: Image? = nil,
    onRemove: (() -> Void)? = nil,
    @ViewBuilder label: () -> Label
  ) {
    source = isSelected.map(Source.binding) ?? .none
    self.size = size
    self.icon = icon
    self.onRemove = onRemove
    self.label = label()
  }

  public init<Value: Hashable>(
    value: Value,
    size: ChipSize? = nil,
    icon: Image? = nil,
    onRemove: (() -> Void)? = nil,
    @ViewBuilder label: () -> Label
  ) {
    source = .value(AnyHashable(value))
    self.size = size
    self.icon = icon
    self.onRemove = onRemove
    self.label = label()
  }

  private var selected: Bool {
    switch source {
    case .none: false
    case .binding(let binding): binding.wrappedValue
    case .value(let value): group?.isSelected(value) ?? false
    }
  }

  private var selectable: Bool {
    switch source {
    case .none: false
    case .binding: true
    case .value: group != nil
    }
  }

  private func activate() -> Bool {
    switch source {
    case .none:
      return false
    case .binding(let binding):
      binding.wrappedValue.toggle()
      return true
    case .value(let value):
      guard let group else { return false }
      group.toggle(value)
      return true
    }
  }

  public var body: some View {
    let resolved = size ?? group?.size ?? .default
    let selected = selected
    Group {
      if selectable {
        HapticButton(style: PressReader { pressed in
          surface(size: resolved, selected: selected, pressed: pressed)
        }) {
          activate()
        }
        .accessibilityAddTraits(selected ? .isSelected : [])
      } else {
        surface(size: resolved, selected: selected, pressed: false)
          .accessibilityElement(children: .combine)
      }
    }
    .overlay(alignment: .trailing) {
      if let onRemove {
        Button(action: onRemove) {
          Color.clear
            .frame(width: resolved.height, height: resolved.height)
            .contentShape(Rectangle())
        }
        .buttonStyle(.plain)
        .accessibilityHidden(true)
      }
    }
    .accessibilityActions {
      if let onRemove {
        Button("Remove", action: onRemove)
      }
    }
  }

  private func surface(size: ChipSize, selected: Bool, pressed: Bool) -> some View {
    HStack(spacing: 6) {
      if let icon {
        icon
          .frame(width: size.iconSize, height: size.iconSize)
          .accessibilityHidden(true)
      }
      label
        .scaledFont(size.textStyle, weight: .medium)
        .lineLimit(1)
      if onRemove != nil {
        Ph.x.bold
          .frame(width: size.iconSize - 4, height: size.iconSize - 4)
          .opacity(0.7)
          .accessibilityHidden(true)
      }
    }
    .foregroundStyle(foreground(selected: selected))
    .padding(.horizontal, size.padding)
    .frame(height: size.height)
    .background(background(selected: selected), in: Capsule())
    .opacity(isEnabled ? (pressed ? 0.8 : 1) : 0.5)
    .contentShape(Capsule())
  }

  private func foreground(selected: Bool) -> Color {
    selected ? EsuissColor.background : EsuissColor.secondaryForeground
  }

  private func background(selected: Bool) -> Color {
    selected ? EsuissColor.foreground : EsuissColor.secondary
  }
}

extension Chip where Label == Text {
  public init(
    _ title: LocalizedStringKey,
    isSelected: Binding<Bool>? = nil,
    size: ChipSize? = nil,
    icon: Image? = nil,
    onRemove: (() -> Void)? = nil
  ) {
    self.init(isSelected: isSelected, size: size, icon: icon, onRemove: onRemove) { Text(title) }
  }

  public init<Value: Hashable>(
    _ title: LocalizedStringKey,
    value: Value,
    size: ChipSize? = nil,
    icon: Image? = nil,
    onRemove: (() -> Void)? = nil
  ) {
    self.init(value: value, size: size, icon: icon, onRemove: onRemove) { Text(title) }
  }
}
```

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

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

- `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:

```kt title="kotlin/components/Chip.kt"
package com.esuiss.ui.components

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.horizontalScroll
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.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Bold
import com.adamglin.phosphoricons.bold.X
import com.esuiss.ui.theme.Colors
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

enum class ChipSize(
    internal val height: Dp,
    internal val horizontalPadding: Dp,
    internal val iconSize: Dp,
) {
    Default(32.dp, 14.dp, 16.dp),
    Sm(28.dp, 12.dp, 14.dp),
    ;

    internal val textStyle: TextStyle
        get() = if (this == Sm) Typography.xs else Typography.sm
}

private class ChipGroupState(
    val isSelected: (String) -> Boolean,
    val toggle: (String) -> Unit,
    val size: ChipSize,
    val enabled: Boolean,
    val multiple: Boolean,
)

private val LocalChipGroupState = staticCompositionLocalOf<ChipGroupState?> { null }

@Composable
fun ChipGroup(
    value: String?,
    onValueChange: (String?) -> Unit,
    modifier: Modifier = Modifier,
    size: ChipSize = ChipSize.Default,
    scrollable: Boolean = false,
    enabled: Boolean = true,
    contentPadding: PaddingValues = PaddingValues(0.dp),
    content: @Composable () -> Unit,
) {
    val state = ChipGroupState(
        isSelected = { it == value },
        toggle = { onValueChange(if (it == value) null else it) },
        size = size,
        enabled = enabled,
        multiple = false,
    )
    ChipGroupLayout(state, modifier.selectableGroup(), scrollable, contentPadding, content)
}

@Composable
fun ChipGroup(
    values: Set<String>,
    onValuesChange: (Set<String>) -> Unit,
    modifier: Modifier = Modifier,
    size: ChipSize = ChipSize.Default,
    scrollable: Boolean = false,
    enabled: Boolean = true,
    contentPadding: PaddingValues = PaddingValues(0.dp),
    content: @Composable () -> Unit,
) {
    val state = ChipGroupState(
        isSelected = { it in values },
        toggle = { onValuesChange(if (it in values) values - it else values + it) },
        size = size,
        enabled = enabled,
        multiple = true,
    )
    ChipGroupLayout(state, modifier, scrollable, contentPadding, content)
}

@Composable
private fun ChipGroupLayout(
    state: ChipGroupState,
    modifier: Modifier,
    scrollable: Boolean,
    contentPadding: PaddingValues,
    content: @Composable () -> Unit,
) {
    CompositionLocalProvider(LocalChipGroupState provides state) {
        if (scrollable) {
            Row(
                modifier = modifier
                    .horizontalScroll(rememberScrollState())
                    .padding(contentPadding),
                horizontalArrangement = Arrangement.spacedBy(8.dp),
                verticalAlignment = Alignment.CenterVertically,
            ) {
                content()
            }
        } else {
            FlowRow(
                modifier = modifier.padding(contentPadding),
                horizontalArrangement = Arrangement.spacedBy(8.dp),
                verticalArrangement = Arrangement.spacedBy(8.dp),
            ) {
                content()
            }
        }
    }
}

@Composable
fun Chip(
    text: String,
    value: String,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    icon: ImageVector? = null,
    onRemove: (() -> Unit)? = null,
    removeLabel: String = "Remove $text",
) {
    val group = LocalChipGroupState.current
    val selected = group?.isSelected(value) ?: false
    ChipSurface(
        text = text,
        selected = selected,
        onClick = { group?.toggle(value) },
        multiple = group?.multiple ?: true,
        modifier = modifier,
        size = group?.size ?: ChipSize.Default,
        enabled = enabled && (group?.enabled ?: true),
        icon = icon,
        onRemove = onRemove,
        removeLabel = removeLabel,
    )
}

@Composable
fun Chip(
    text: String,
    selected: Boolean,
    onSelectedChange: (Boolean) -> Unit,
    modifier: Modifier = Modifier,
    size: ChipSize = LocalChipGroupState.current?.size ?: ChipSize.Default,
    enabled: Boolean = true,
    icon: ImageVector? = null,
    onRemove: (() -> Unit)? = null,
    removeLabel: String = "Remove $text",
) {
    ChipSurface(
        text = text,
        selected = selected,
        onClick = { onSelectedChange(!selected) },
        multiple = true,
        modifier = modifier,
        size = size,
        enabled = enabled,
        icon = icon,
        onRemove = onRemove,
        removeLabel = removeLabel,
    )
}

private fun chipBackground(colors: Colors, selected: Boolean): Color =
    if (selected) colors.foreground else colors.secondary

private fun chipForeground(colors: Colors, selected: Boolean): Color =
    if (selected) colors.background else colors.foreground

@Composable
private fun ChipSurface(
    text: String,
    selected: Boolean,
    onClick: () -> Unit,
    multiple: Boolean,
    modifier: Modifier,
    size: ChipSize,
    enabled: Boolean,
    icon: ImageVector?,
    onRemove: (() -> Unit)?,
    removeLabel: String,
) {
    val colors = EsuissTheme.colors
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val haptics = rememberHaptics()
    val select = {
        if (multiple) haptics.toggle(!selected) else haptics.selection()
        onClick()
    }
    val scale by animateFloatAsState(if (pressed && enabled) 0.96f else 1f, tween(150), label = "chip")
    val foreground = chipForeground(colors, selected)
    val semantics = if (multiple) {
        Modifier.toggleable(
            value = selected,
            interactionSource = interaction,
            indication = null,
            enabled = enabled,
            role = Role.Checkbox,
            onValueChange = { select() },
        )
    } else {
        Modifier.selectable(
            selected = selected,
            interactionSource = interaction,
            indication = null,
            enabled = enabled,
            role = Role.RadioButton,
            onClick = select,
        )
    }
    Row(
        modifier = modifier
            .graphicsLayer {
                scaleX = scale
                scaleY = scale
                alpha = when {
                    !enabled -> 0.5f
                    pressed -> 0.8f
                    else -> 1f
                }
            }
            .height(size.height)
            .clip(CircleShape)
            .background(chipBackground(colors, selected), CircleShape)
            .then(semantics)
            .padding(start = if (icon != null) size.horizontalPadding - 4.dp else size.horizontalPadding)
            .padding(end = if (onRemove != null) 4.dp else size.horizontalPadding),
        horizontalArrangement = Arrangement.spacedBy(6.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        CompositionLocalProvider(
            LocalContentColor provides foreground,
            LocalIconSize provides size.iconSize,
        ) {
            if (icon != null) Icon(icon, contentDescription = null, size = size.iconSize, tint = foreground)
            BasicText(
                text,
                style = size.textStyle.copy(color = foreground, fontWeight = FontWeight.Medium),
                maxLines = 1,
            )
            if (onRemove != null) {
                Box(
                    modifier = Modifier
                        .size(size.height - 8.dp)
                        .clip(CircleShape)
                        .clickable(
                            interactionSource = remember { MutableInteractionSource() },
                            indication = null,
                            enabled = enabled,
                            role = Role.Button,
                            onClick = onRemove,
                        )
                        .semantics { contentDescription = removeLabel },
                    contentAlignment = Alignment.Center,
                ) {
                    Icon(
                        PhosphorIcons.Bold.X,
                        contentDescription = null,
                        size = size.iconSize - 4.dp,
                        tint = foreground.copy(alpha = foreground.alpha * 0.7f),
                    )
                }
            }
        }
    }
}
```

- `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/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/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 { Chip, ChipGroup } from "@/components/ui/chip"
```

```tsx
<Chip>Open now</Chip>
```

Use `selected` with `onSelectedChange`, or `defaultSelected`, on a standalone chip. Inside a `ChipGroup`, give each chip a `value` and control the group with `value`, `defaultValue` and `onValueChange` (arrays of values).

The group's `size` is passed to every chip unless a chip sets its own. Use `removeLabel` to localize the remove button's label.

`chipVariants` is also exported if you need the classes elsewhere.

## Examples

### States

Unselected, selected and disabled chips.

![Preview](https://ui.suiss.app/previews/components-chip--states.light.webp)

```tsx
import * as React from "react"
import { View } from "react-native"
import { Chip } from "@/components/ui/chip"
import { Text } from "@/components/ui/text"

function Section({
  label,
  children,
}: {
  label: string
  children: React.ReactNode
}) {
  return (
    <View className="gap-2">
      <Text variant="muted">{label}</Text>
      {children}
    </View>
  )
}

export function ChipStatesExample() {
  return (
    <View className="gap-4">
      <Section label="Unselected">
        <Chip>Open now</Chip>
      </Section>
      <Section label="Selected">
        <Chip defaultSelected>Open now</Chip>
      </Section>
      <Section label="Disabled">
        <View className="flex-row gap-2">
          <Chip disabled>Open now</Chip>
          <Chip disabled defaultSelected>
            Open now
          </Chip>
        </View>
      </Section>
    </View>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct ChipStatesExample: View {
  var body: some View {
    VStack(alignment: .leading, spacing: 16) {
      ChipSection(label: "Unselected") {
        ChipToggleDemo(title: "Open now", initial: false)
      }
      ChipSection(label: "Selected") {
        ChipToggleDemo(title: "Open now", initial: true)
      }
      ChipSection(label: "Disabled") {
        HStack(spacing: 8) {
          Chip("Open now", isSelected: .constant(false))
          Chip("Open now", isSelected: .constant(true))
        }
        .disabled(true)
      }
    }
  }
}

struct ChipSection<Content: View>: View {
  let label: LocalizedStringKey
  @ViewBuilder let content: () -> Content

  var body: some View {
    VStack(alignment: .leading, spacing: 8) {
      Text(label).textVariant(.muted)
      content()
    }
  }
}

struct ChipToggleDemo: View {
  let title: LocalizedStringKey
  var size: ChipSize?
  var icon: Image?
  var onRemove: (() -> Void)?

  @State private var selected: Bool

  init(
    title: LocalizedStringKey,
    initial: Bool,
    size: ChipSize? = nil,
    icon: Image? = nil,
    onRemove: (() -> Void)? = nil
  ) {
    self.title = title
    self.size = size
    self.icon = icon
    self.onRemove = onRemove
    _selected = State(initialValue: initial)
  }

  var body: some View {
    Chip(title, isSelected: $selected, size: size, icon: icon, onRemove: onRemove)
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Chip
import com.esuiss.ui.components.ChipSize
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant

@Composable
fun ChipItem(
    text: String,
    defaultSelected: Boolean = false,
    size: ChipSize = ChipSize.Default,
    enabled: Boolean = true,
    icon: ImageVector? = null,
    onRemove: (() -> Unit)? = null,
) {
    var selected by rememberSaveable { mutableStateOf(defaultSelected) }
    Chip(
        text,
        selected = selected,
        onSelectedChange = { selected = it },
        size = size,
        enabled = enabled,
        icon = icon,
        onRemove = onRemove,
    )
}

@Composable
fun ChipSection(label: String, content: @Composable () -> Unit) {
    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
        Text(label, variant = TextVariant.Muted)
        content()
    }
}

@Composable
fun ChipStates() {
    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
        ChipSection("Unselected") { ChipItem("Open now") }
        ChipSection("Selected") { ChipItem("Open now", defaultSelected = true) }
        ChipSection("Disabled") {
            Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                ChipItem("Open now", enabled = false)
                ChipItem("Open now", defaultSelected = true, enabled = false)
            }
        }
    }
}
```

### Sizes

The `default` and `sm` sizes.

![Preview](https://ui.suiss.app/previews/components-chip--sizes.light.webp)

```tsx
import { StarIcon } from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import { Chip } from "@/components/ui/chip"
import { Text } from "@/components/ui/text"

function Section({
  label,
  children,
}: {
  label: string
  children: React.ReactNode
}) {
  return (
    <View className="gap-2">
      <Text variant="muted">{label}</Text>
      {children}
    </View>
  )
}

export function ChipSizesExample() {
  return (
    <View className="gap-4">
      <Section label="Default">
        <View className="flex-row gap-2">
          <Chip icon={StarIcon}>Top rated</Chip>
          <Chip icon={StarIcon} defaultSelected>
            Top rated
          </Chip>
          <Chip onRemove={() => undefined}>Nearby</Chip>
        </View>
      </Section>
      <Section label="Small">
        <View className="flex-row gap-2">
          <Chip size="sm" icon={StarIcon}>
            Top rated
          </Chip>
          <Chip size="sm" icon={StarIcon} defaultSelected>
            Top rated
          </Chip>
          <Chip size="sm" onRemove={() => undefined}>
            Nearby
          </Chip>
        </View>
      </Section>
    </View>
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct ChipSizesExample: View {
  var body: some View {
    VStack(alignment: .leading, spacing: 16) {
      ChipSection(label: "Default") {
        HStack(spacing: 8) {
          ChipToggleDemo(title: "Top rated", initial: false, icon: Ph.star.regular)
          ChipToggleDemo(title: "Top rated", initial: true, icon: Ph.star.regular)
          ChipToggleDemo(title: "Nearby", initial: false, onRemove: {})
        }
      }
      ChipSection(label: "Small") {
        HStack(spacing: 8) {
          ChipToggleDemo(title: "Top rated", initial: false, size: .sm, icon: Ph.star.regular)
          ChipToggleDemo(title: "Top rated", initial: true, size: .sm, icon: Ph.star.regular)
          ChipToggleDemo(title: "Nearby", initial: false, size: .sm, onRemove: {})
        }
      }
    }
  }
}

struct ChipSection<Content: View>: View {
  let label: LocalizedStringKey
  @ViewBuilder let content: () -> Content

  var body: some View {
    VStack(alignment: .leading, spacing: 8) {
      Text(label).textVariant(.muted)
      content()
    }
  }
}

struct ChipToggleDemo: View {
  let title: LocalizedStringKey
  var size: ChipSize?
  var icon: Image?
  var onRemove: (() -> Void)?

  @State private var selected: Bool

  init(
    title: LocalizedStringKey,
    initial: Bool,
    size: ChipSize? = nil,
    icon: Image? = nil,
    onRemove: (() -> Void)? = nil
  ) {
    self.title = title
    self.size = size
    self.icon = icon
    self.onRemove = onRemove
    _selected = State(initialValue: initial)
  }

  var body: some View {
    Chip(title, isSelected: $selected, size: size, icon: icon, onRemove: onRemove)
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.Star
import com.esuiss.ui.components.Chip
import com.esuiss.ui.components.ChipSize
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant

@Composable
fun ChipItem(
    text: String,
    defaultSelected: Boolean = false,
    size: ChipSize = ChipSize.Default,
    enabled: Boolean = true,
    icon: ImageVector? = null,
    onRemove: (() -> Unit)? = null,
) {
    var selected by rememberSaveable { mutableStateOf(defaultSelected) }
    Chip(
        text,
        selected = selected,
        onSelectedChange = { selected = it },
        size = size,
        enabled = enabled,
        icon = icon,
        onRemove = onRemove,
    )
}

@Composable
fun ChipSection(label: String, content: @Composable () -> Unit) {
    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
        Text(label, variant = TextVariant.Muted)
        content()
    }
}

@Composable
fun ChipSizes() {
    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
        ChipSection("Default") {
            Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                ChipItem("Top rated", icon = PhosphorIcons.Regular.Star)
                ChipItem("Top rated", defaultSelected = true, icon = PhosphorIcons.Regular.Star)
                ChipItem("Nearby", onRemove = {})
            }
        }
        ChipSection("Small") {
            Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                ChipItem("Top rated", size = ChipSize.Sm, icon = PhosphorIcons.Regular.Star)
                ChipItem("Top rated", defaultSelected = true, size = ChipSize.Sm, icon = PhosphorIcons.Regular.Star)
                ChipItem("Nearby", size = ChipSize.Sm, onRemove = {})
            }
        }
    }
}
```

### With icon

Chips with a leading icon.

![Preview](https://ui.suiss.app/previews/components-chip--with-icon.light.webp)

```tsx
import { CalendarBlankIcon, CoffeeIcon, ForkKnifeIcon, MapPinIcon } from "phosphor-react-native"
import { View } from "react-native"
import { Chip } from "@/components/ui/chip"

export function ChipWithIconExample() {
  return (
    <View className="flex-row flex-wrap gap-2">
      <Chip icon={CoffeeIcon}>Coffee</Chip>
      <Chip icon={ForkKnifeIcon} defaultSelected>
        Restaurants
      </Chip>
      <Chip icon={MapPinIcon}>Nearby</Chip>
      <Chip icon={CalendarBlankIcon} disabled>
        Tonight
      </Chip>
    </View>
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct ChipWithIconExample: View {
  var body: some View {
    FlowRow(spacing: 8) {
      ChipToggleDemo(title: "Coffee", initial: false, icon: Ph.coffee.regular)
      ChipToggleDemo(title: "Restaurants", initial: true, icon: Ph.forkKnife.regular)
      ChipToggleDemo(title: "Nearby", initial: false, icon: Ph.mapPin.regular)
      ChipToggleDemo(title: "Tonight", initial: false, icon: Ph.calendarBlank.regular)
        .disabled(true)
    }
  }
}

struct ChipToggleDemo: View {
  let title: LocalizedStringKey
  var size: ChipSize?
  var icon: Image?
  var onRemove: (() -> Void)?

  @State private var selected: Bool

  init(
    title: LocalizedStringKey,
    initial: Bool,
    size: ChipSize? = nil,
    icon: Image? = nil,
    onRemove: (() -> Void)? = nil
  ) {
    self.title = title
    self.size = size
    self.icon = icon
    self.onRemove = onRemove
    _selected = State(initialValue: initial)
  }

  var body: some View {
    Chip(title, isSelected: $selected, size: size, icon: icon, onRemove: onRemove)
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.CalendarBlank
import com.adamglin.phosphoricons.regular.Coffee
import com.adamglin.phosphoricons.regular.ForkKnife
import com.adamglin.phosphoricons.regular.MapPin
import com.esuiss.ui.components.Chip
import com.esuiss.ui.components.ChipSize

@Composable
fun ChipItem(
    text: String,
    defaultSelected: Boolean = false,
    size: ChipSize = ChipSize.Default,
    enabled: Boolean = true,
    icon: ImageVector? = null,
    onRemove: (() -> Unit)? = null,
) {
    var selected by rememberSaveable { mutableStateOf(defaultSelected) }
    Chip(
        text,
        selected = selected,
        onSelectedChange = { selected = it },
        size = size,
        enabled = enabled,
        icon = icon,
        onRemove = onRemove,
    )
}

@Composable
fun ChipWithIcon() {
    FlowRow(
        Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.spacedBy(8.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp),
    ) {
        ChipItem("Coffee", icon = PhosphorIcons.Regular.Coffee)
        ChipItem("Restaurants", defaultSelected = true, icon = PhosphorIcons.Regular.ForkKnife)
        ChipItem("Nearby", icon = PhosphorIcons.Regular.MapPin)
        ChipItem("Tonight", enabled = false, icon = PhosphorIcons.Regular.CalendarBlank)
    }
}
```

### Removable

Chips with a remove button through `onRemove`.

![Preview](https://ui.suiss.app/previews/components-chip--removable.light.webp)

```tsx
import * as React from "react"
import { View } from "react-native"
import { Chip } from "@/components/ui/chip"
import { Text } from "@/components/ui/text"

function RemovableDemo() {
  const [tags, setTags] = React.useState([
    "Design",
    "Mobile",
    "Accessibility",
    "Motion",
  ])
  return (
    <View className="gap-3">
      <View className="flex-row flex-wrap gap-2">
        {tags.map((tag) => (
          <Chip
            key={tag}
            onRemove={() =>
              setTags((current) => current.filter((item) => item !== tag))
            }
            removeLabel={`Remove ${tag}`}
          >
            {tag}
          </Chip>
        ))}
      </View>
      <Text variant="muted">{tags.length} tags</Text>
    </View>
  )
}

export function ChipRemovableExample() {
  return (
    <RemovableDemo />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct ChipRemovableDemo: View {
  @State private var tags = ["Design", "Mobile", "Accessibility", "Motion"]
  @State private var selected: Set<String> = []

  var body: some View {
    VStack(alignment: .leading, spacing: 12) {
      FlowRow(spacing: 8) {
        ForEach(tags, id: \.self) { tag in
          Chip(isSelected: binding(tag), onRemove: {
            withAnimation { tags.removeAll { $0 == tag } }
          }) {
            Text(verbatim: tag)
          }
        }
      }
      Text("\(tags.count) tags").textVariant(.muted)
    }
  }

  func binding(_ tag: String) -> Binding<Bool> {
    Binding(
      get: { selected.contains(tag) },
      set: { isOn in
        if isOn { selected.insert(tag) } else { selected.remove(tag) }
      }
    )
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Chip
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant

@Composable
fun ChipRemovable() {
    var tags by rememberSaveable { mutableStateOf(listOf("Design", "Mobile", "Accessibility", "Motion")) }
    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
        FlowRow(
            Modifier.fillMaxWidth(),
            horizontalArrangement = Arrangement.spacedBy(8.dp),
            verticalArrangement = Arrangement.spacedBy(8.dp),
        ) {
            tags.forEach { tag ->
                var selected by rememberSaveable(tag) { mutableStateOf(false) }
                Chip(
                    tag,
                    selected = selected,
                    onSelectedChange = { selected = it },
                    onRemove = { tags = tags.filter { it != tag } },
                    removeLabel = "Remove $tag",
                )
            }
        }
        Text("${tags.size} tags", variant = TextVariant.Muted)
    }
}
```

### Controlled

A chip controlled with `selected` and `onSelectedChange`.

![Preview](https://ui.suiss.app/previews/components-chip--controlled.light.webp)

```tsx
import * as React from "react"
import { View } from "react-native"
import { Chip } from "@/components/ui/chip"
import { Text } from "@/components/ui/text"

function ControlledDemo() {
  const [selected, setSelected] = React.useState(true)
  return (
    <View className="gap-3">
      <Chip selected={selected} onSelectedChange={setSelected}>
        Free delivery
      </Chip>
      <Text variant="muted">{selected ? "Selected" : "Not selected"}</Text>
    </View>
  )
}

export function ChipControlledExample() {
  return (
    <ControlledDemo />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct ChipControlledDemo: View {
  @State private var selected = true

  var body: some View {
    VStack(alignment: .leading, spacing: 12) {
      Chip("Free delivery", isSelected: $selected)
      Text(selected ? "Selected" : "Not selected").textVariant(.muted)
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Chip
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant

@Composable
fun ChipControlled() {
    var selected by rememberSaveable { mutableStateOf(true) }
    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
        Chip("Free delivery", selected = selected, onSelectedChange = { selected = it })
        Text(if (selected) "Selected" else "Not selected", variant = TextVariant.Muted)
    }
}
```

### Group single

A group that allows one selected chip.

![Preview](https://ui.suiss.app/previews/components-chip--group-single.light.webp)

```tsx
import * as React from "react"
import { View } from "react-native"
import { Chip, ChipGroup } from "@/components/ui/chip"
import { Text } from "@/components/ui/text"

const categories = [
  { value: "all", label: "All" },
  { value: "coffee", label: "Coffee" },
  { value: "lunch", label: "Lunch" },
  { value: "dinner", label: "Dinner" },
  { value: "bakery", label: "Bakery" },
  { value: "bars", label: "Bars" },
  { value: "desserts", label: "Desserts" },
]

function SingleGroupDemo() {
  const [value, setValue] = React.useState(["all"])
  return (
    <View className="gap-3">
      <ChipGroup value={value} onValueChange={setValue}>
        {categories.slice(0, 4).map((category) => (
          <Chip key={category.value} value={category.value}>
            {category.label}
          </Chip>
        ))}
      </ChipGroup>
      <Text variant="muted">Selected: {value.join(", ") || "none"}</Text>
    </View>
  )
}

export function ChipGroupSingleExample() {
  return (
    <SingleGroupDemo />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct ChipCategory: Hashable {
  let value: String
  let label: String
}

let chipCategories = [
  ChipCategory(value: "all", label: "All"),
  ChipCategory(value: "coffee", label: "Coffee"),
  ChipCategory(value: "lunch", label: "Lunch"),
  ChipCategory(value: "dinner", label: "Dinner"),
  ChipCategory(value: "bakery", label: "Bakery"),
  ChipCategory(value: "bars", label: "Bars"),
  ChipCategory(value: "desserts", label: "Desserts"),
]

struct ChipGroupSingleDemo: View {
  @State private var value: String? = "all"

  var body: some View {
    VStack(alignment: .leading, spacing: 12) {
      ChipGroup(selection: $value) {
        ForEach(chipCategories.prefix(4), id: \.value) { category in
          Chip(value: category.value) { Text(verbatim: category.label) }
        }
      }
      Text("Selected: \(value ?? "none")").textVariant(.muted)
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.adamglin.phosphoricons.regular.Coffee
import com.esuiss.ui.components.Chip
import com.esuiss.ui.components.ChipGroup
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant

val ChipCategories = listOf(
    "all" to "All",
    "coffee" to "Coffee",
    "lunch" to "Lunch",
    "dinner" to "Dinner",
    "bakery" to "Bakery",
    "bars" to "Bars",
    "desserts" to "Desserts",
)

@Composable
fun ChipGroupSingle() {
    var value by rememberSaveable { mutableStateOf<String?>("all") }
    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
        ChipGroup(value = value, onValueChange = { value = it }) {
            ChipCategories.take(4).forEach { (key, label) -> Chip(label, value = key) }
        }
        Text("Selected: ${value ?: "none"}", variant = TextVariant.Muted)
    }
}
```

### Group multiple

A group with `multiple` selection.

![Preview](https://ui.suiss.app/previews/components-chip--group-multiple.light.webp)

```tsx
import * as React from "react"
import { View } from "react-native"
import { Chip, ChipGroup } from "@/components/ui/chip"
import { Text } from "@/components/ui/text"

const categories = [
  { value: "all", label: "All" },
  { value: "coffee", label: "Coffee" },
  { value: "lunch", label: "Lunch" },
  { value: "dinner", label: "Dinner" },
  { value: "bakery", label: "Bakery" },
  { value: "bars", label: "Bars" },
  { value: "desserts", label: "Desserts" },
]

function MultipleGroupDemo() {
  const [value, setValue] = React.useState(["coffee", "bakery"])
  return (
    <View className="gap-3">
      <ChipGroup multiple value={value} onValueChange={setValue}>
        {categories.slice(1).map((category) => (
          <Chip key={category.value} value={category.value}>
            {category.label}
          </Chip>
        ))}
      </ChipGroup>
      <Text variant="muted">Selected: {value.join(", ") || "none"}</Text>
    </View>
  )
}

export function ChipGroupMultipleExample() {
  return (
    <MultipleGroupDemo />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct ChipCategory: Hashable {
  let value: String
  let label: String
}

let chipCategories = [
  ChipCategory(value: "all", label: "All"),
  ChipCategory(value: "coffee", label: "Coffee"),
  ChipCategory(value: "lunch", label: "Lunch"),
  ChipCategory(value: "dinner", label: "Dinner"),
  ChipCategory(value: "bakery", label: "Bakery"),
  ChipCategory(value: "bars", label: "Bars"),
  ChipCategory(value: "desserts", label: "Desserts"),
]

struct ChipGroupMultipleDemo: View {
  @State private var value: Set<String> = ["coffee", "bakery"]

  var body: some View {
    let selected = chipCategories.map(\.value).filter { value.contains($0) }
    VStack(alignment: .leading, spacing: 12) {
      ChipGroup(selection: $value) {
        ForEach(chipCategories.dropFirst(), id: \.value) { category in
          Chip(value: category.value) { Text(verbatim: category.label) }
        }
      }
      Text("Selected: \(selected.isEmpty ? "none" : selected.joined(separator: ", "))").textVariant(.muted)
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.adamglin.phosphoricons.regular.Coffee
import com.esuiss.ui.components.Chip
import com.esuiss.ui.components.ChipGroup
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant

val ChipCategories = listOf(
    "all" to "All",
    "coffee" to "Coffee",
    "lunch" to "Lunch",
    "dinner" to "Dinner",
    "bakery" to "Bakery",
    "bars" to "Bars",
    "desserts" to "Desserts",
)

@Composable
fun ChipGroupMultiple() {
    var values by rememberSaveable { mutableStateOf(listOf("coffee", "bakery")) }
    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
        ChipGroup(values = values.toSet(), onValuesChange = { next -> values = values.filter { it in next } + next.filter { it !in values } }) {
            ChipCategories.drop(1).forEach { (key, label) -> Chip(label, value = key) }
        }
        Text("Selected: ${values.joinToString(", ").ifEmpty { "none" }}", variant = TextVariant.Muted)
    }
}
```

### Group uncontrolled

A group using `defaultValue`.

![Preview](https://ui.suiss.app/previews/components-chip--group-uncontrolled.light.webp)

```tsx
import { Chip, ChipGroup } from "@/components/ui/chip"

const categories = [
  { value: "all", label: "All" },
  { value: "coffee", label: "Coffee" },
  { value: "lunch", label: "Lunch" },
  { value: "dinner", label: "Dinner" },
  { value: "bakery", label: "Bakery" },
  { value: "bars", label: "Bars" },
  { value: "desserts", label: "Desserts" },
]

export function ChipGroupUncontrolledExample() {
  return (
    <ChipGroup multiple defaultValue={["lunch"]} size="sm">
      {categories.slice(1).map((category) => (
        <Chip key={category.value} value={category.value}>
          {category.label}
        </Chip>
      ))}
    </ChipGroup>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct ChipCategory: Hashable {
  let value: String
  let label: String
}

let chipCategories = [
  ChipCategory(value: "all", label: "All"),
  ChipCategory(value: "coffee", label: "Coffee"),
  ChipCategory(value: "lunch", label: "Lunch"),
  ChipCategory(value: "dinner", label: "Dinner"),
  ChipCategory(value: "bakery", label: "Bakery"),
  ChipCategory(value: "bars", label: "Bars"),
  ChipCategory(value: "desserts", label: "Desserts"),
]

struct ChipGroupUncontrolledDemo: View {
  @State private var value: Set<String> = ["lunch"]

  var body: some View {
    ChipGroup(selection: $value, size: .sm) {
      ForEach(chipCategories.dropFirst(), id: \.value) { category in
        Chip(value: category.value) { Text(verbatim: category.label) }
      }
    }
  }
}
```

```kotlin
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import com.adamglin.phosphoricons.regular.Coffee
import com.esuiss.ui.components.Chip
import com.esuiss.ui.components.ChipGroup
import com.esuiss.ui.components.ChipSize

val ChipCategories = listOf(
    "all" to "All",
    "coffee" to "Coffee",
    "lunch" to "Lunch",
    "dinner" to "Dinner",
    "bakery" to "Bakery",
    "bars" to "Bars",
    "desserts" to "Desserts",
)

@Composable
fun ChipGroupUncontrolled() {
    var values by rememberSaveable { mutableStateOf(listOf("lunch")) }
    ChipGroup(values = values.toSet(), onValuesChange = { values = it.toList() }, size = ChipSize.Sm) {
        ChipCategories.drop(1).forEach { (key, label) -> Chip(label, value = key) }
    }
}
```

### Group scrollable

A single scrolling row with `scrollable`.

![Preview](https://ui.suiss.app/previews/components-chip--group-scrollable.light.webp)

```tsx
import { View } from "react-native"
import { Chip, ChipGroup } from "@/components/ui/chip"

const categories = [
  { value: "all", label: "All" },
  { value: "coffee", label: "Coffee" },
  { value: "lunch", label: "Lunch" },
  { value: "dinner", label: "Dinner" },
  { value: "bakery", label: "Bakery" },
  { value: "bars", label: "Bars" },
  { value: "desserts", label: "Desserts" },
]

export function ChipGroupScrollableExample() {
  return (
    <View className="-mx-4">
      <ChipGroup scrollable defaultValue={["all"]} className="px-4">
        {categories.map((category) => (
          <Chip key={category.value} value={category.value}>
            {category.label}
          </Chip>
        ))}
      </ChipGroup>
    </View>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct ChipCategory: Hashable {
  let value: String
  let label: String
}

let chipCategories = [
  ChipCategory(value: "all", label: "All"),
  ChipCategory(value: "coffee", label: "Coffee"),
  ChipCategory(value: "lunch", label: "Lunch"),
  ChipCategory(value: "dinner", label: "Dinner"),
  ChipCategory(value: "bakery", label: "Bakery"),
  ChipCategory(value: "bars", label: "Bars"),
  ChipCategory(value: "desserts", label: "Desserts"),
]

struct ChipGroupScrollableDemo: View {
  @State private var value: String? = "all"

  var body: some View {
    ChipGroup(selection: $value, scrollable: true) {
      ForEach(chipCategories, id: \.value) { category in
        Chip(value: category.value) { Text(verbatim: category.label) }
      }
    }
    .contentMargins(.horizontal, 16, for: .scrollContent)
    .padding(.horizontal, -16)
  }
}
```

```kotlin
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layout
import androidx.compose.ui.unit.dp
import com.adamglin.phosphoricons.regular.Coffee
import com.esuiss.ui.components.Chip
import com.esuiss.ui.components.ChipGroup

val ChipCategories = listOf(
    "all" to "All",
    "coffee" to "Coffee",
    "lunch" to "Lunch",
    "dinner" to "Dinner",
    "bakery" to "Bakery",
    "bars" to "Bars",
    "desserts" to "Desserts",
)

@Composable
fun ChipGroupScrollable() {
    var value by rememberSaveable { mutableStateOf<String?>("all") }
    ChipGroup(
        value = value,
        onValueChange = { value = it },
        modifier = Modifier.layout { measurable, constraints ->
            val bleed = 16.dp.roundToPx()
            val width = constraints.maxWidth + bleed * 2
            val placeable = measurable.measure(constraints.copy(minWidth = width, maxWidth = width))
            layout(constraints.maxWidth, placeable.height) { placeable.place(-bleed, 0) }
        },
        scrollable = true,
        contentPadding = PaddingValues(horizontal = 16.dp),
    ) {
        ChipCategories.forEach { (key, label) -> Chip(label, value = key) }
    }
}
```

### Group disabled

A disabled group.

![Preview](https://ui.suiss.app/previews/components-chip--group-disabled.light.webp)

```tsx
import { Chip, ChipGroup } from "@/components/ui/chip"

const categories = [
  { value: "all", label: "All" },
  { value: "coffee", label: "Coffee" },
  { value: "lunch", label: "Lunch" },
  { value: "dinner", label: "Dinner" },
  { value: "bakery", label: "Bakery" },
  { value: "bars", label: "Bars" },
  { value: "desserts", label: "Desserts" },
]

export function ChipGroupDisabledExample() {
  return (
    <ChipGroup disabled defaultValue={["coffee"]}>
      {categories.slice(1, 4).map((category) => (
        <Chip key={category.value} value={category.value}>
          {category.label}
        </Chip>
      ))}
    </ChipGroup>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct ChipCategory: Hashable {
  let value: String
  let label: String
}

let chipCategories = [
  ChipCategory(value: "all", label: "All"),
  ChipCategory(value: "coffee", label: "Coffee"),
  ChipCategory(value: "lunch", label: "Lunch"),
  ChipCategory(value: "dinner", label: "Dinner"),
  ChipCategory(value: "bakery", label: "Bakery"),
  ChipCategory(value: "bars", label: "Bars"),
  ChipCategory(value: "desserts", label: "Desserts"),
]

struct ChipGroupDisabledDemo: View {
  @State private var value: String? = "coffee"

  var body: some View {
    ChipGroup(selection: $value) {
      ForEach(chipCategories[1..<4], id: \.value) { category in
        Chip(value: category.value) { Text(verbatim: category.label) }
      }
    }
    .disabled(true)
  }
}
```

```kotlin
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import com.adamglin.phosphoricons.regular.Coffee
import com.esuiss.ui.components.Chip
import com.esuiss.ui.components.ChipGroup

val ChipCategories = listOf(
    "all" to "All",
    "coffee" to "Coffee",
    "lunch" to "Lunch",
    "dinner" to "Dinner",
    "bakery" to "Bakery",
    "bars" to "Bars",
    "desserts" to "Desserts",
)

@Composable
fun ChipGroupDisabled() {
    var value by rememberSaveable { mutableStateOf<String?>("coffee") }
    ChipGroup(value = value, onValueChange = { value = it }, enabled = false) {
        ChipCategories.subList(1, 4).forEach { (key, label) -> Chip(label, value = key) }
    }
}
```

## API Reference

### Chip

Selectable pill.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `selected` | `boolean` | - | Whether the chip is selected, for controlled use. |
| `defaultSelected` | `boolean` | - | Whether the chip starts selected when uncontrolled. |
| `onSelectedChange` | `(selected: boolean, details) => void` | - | Called when the chip is toggled. |
| `size` | `"default" \| "sm"` | `"default"` | The chip height; inherited from a surrounding group. |
| `icon` | `PhosphorIcon` | - | An icon shown before the label. |
| `onRemove` | `() => void` | - | Shows a remove button and is called when it is pressed. |
| `removeLabel` | `string` | `"Remove"` | Accessibility label of the remove button. |

Also accepts the props of `ChipProps`.

### ChipGroup

Manages selection and layout for several chips.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `size` | `ChipSize` | `"default"` | Size passed to every chip. |
| `scrollable` | `boolean` | `false` | Shows chips in a horizontal scroll view instead of wrapping. |

Also accepts the props of `React.ComponentProps<typeof ToggleGroupPrimitive>`.

## Accessibility

- A chip has the `togglebutton` role with `checked` state, or the `radio` role in a single-selection group.
- A group has the `radiogroup` role for single selection and `group` for multiple.
- With `onRemove`, the chip exposes a `remove` accessibility action, and the remove button has its own `button` role and label.

## Notes

- Uses `bg-secondary`, `bg-foreground` and `text-background` tokens, with `opacity-80` while pressed and `opacity-50` when disabled; light and dark follow the system appearance.

