# Accordion

A vertically stacked set of headings that each reveal a section of content.

Accordion is an open-source component 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/components/accordion

**React**

**Example**

```tsx
import {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
} from "@/components/ui/accordion"

const items = [
  {
    value: "shipping",
    title: "How long does shipping take?",
    content:
      "Orders ship within two business days and arrive in three to five days.",
  },
  {
    value: "returns",
    title: "Can I return an item?",
    content:
      "Items can be returned within 14 days of delivery for a full refund.",
  },
  {
    value: "support",
    title: "How do I contact support?",
    content:
      "Reach the support team from the help center, available around the clock.",
  },
]

export default function AccordionDemo() {
  return (
    <div className="w-96">
      <Accordion>
        {items.map((item) => (
          <AccordionItem key={item.value} value={item.value}>
            <AccordionTrigger>{item.title}</AccordionTrigger>
            <AccordionContent>{item.content}</AccordionContent>
          </AccordionItem>
        ))}
      </Accordion>
    </div>
  )
}
```

Accordion wraps the accordion primitive of the chosen base. An `Accordion` holds one or more `AccordionItem` parts; each item pairs an `AccordionTrigger` with an `AccordionContent` panel. The trigger renders inside a heading element and shows a caret that flips between down and up as the panel opens and closes.

Use an accordion to shorten long pages of related content, such as FAQs or settings groups, where people only need one or a few sections at a time. For a single show-and-hide region, a collapsible is a lighter fit.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/accordion
```

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

### Manual

1. Install the dependencies and add the [`cn` helper](https://ui.suiss.app/docs/installation#the-cn-helper):

```bash title="Terminal"
npm install @base-ui/react @phosphor-icons/react class-variance-authority
```

2. Copy the source into `components/ui/accordion.tsx`:

```tsx title="components/ui/accordion.tsx"
"use client"

import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"
import { CaretDownIcon, CaretUpIcon } from "@phosphor-icons/react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"

const accordionVariants = cva("flex w-full flex-col", {
  variants: {
    variant: {
      default: "",
      filled: "overflow-hidden rounded-2xl bg-muted",
    },
  },
  defaultVariants: {
    variant: "default",
  },
})

function Accordion({
  className,
  variant,
  ...props
}: AccordionPrimitive.Root.Props & VariantProps<typeof accordionVariants>) {
  return (
    <AccordionPrimitive.Root
      data-slot="accordion"
      className={cn(accordionVariants({ variant }), className)}
      {...props}
    />
  )
}

function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
  return (
    <AccordionPrimitive.Item
      data-slot="accordion-item"
      className={cn("ms-4 not-first:border-t", className)}
      {...props}
    />
  )
}

function AccordionTrigger({
  className,
  children,
  ...props
}: AccordionPrimitive.Trigger.Props) {
  return (
    <AccordionPrimitive.Header className="flex">
      <AccordionPrimitive.Trigger
        data-slot="accordion-trigger"
        className={cn(
          "group/accordion-trigger relative flex min-h-11 cursor-pointer flex-1 items-center justify-between gap-6 py-2.75 ps-0 pe-4 text-start text-base outline-none after:absolute after:-inset-s-2 after:inset-e-2 after:top-2 after:h-[calc(100%-1rem)] after:rounded-md hover:underline focus-visible:after:ring-3 focus-visible:after:ring-ring/50 aria-disabled:pointer-events-none **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground aria-disabled:opacity-50",
          className
        )}
        {...props}
      >
        {children}
        <CaretDownIcon
          data-slot="accordion-trigger-icon"
          className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden"
        />
        <CaretUpIcon
          data-slot="accordion-trigger-icon"
          className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline"
        />
      </AccordionPrimitive.Trigger>
    </AccordionPrimitive.Header>
  )
}

function AccordionContent({
  className,
  children,
  ...props
}: AccordionPrimitive.Panel.Props) {
  return (
    <AccordionPrimitive.Panel
      data-slot="accordion-content"
      className="overflow-hidden text-sm text-muted-foreground data-open:animate-accordion-down data-closed:animate-accordion-up"
      {...props}
    >
      <div
        className={cn(
          "pe-10 pb-3 [&_a]:underline [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
          className
        )}
      >
        {children}
      </div>
    </AccordionPrimitive.Panel>
  )
}

export { Accordion, AccordionContent, AccordionItem, AccordionTrigger }
```

3. Update the import paths to match your project.

## Usage

```tsx
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "@/components/ui/accordion"
```

```tsx
<div className="w-96">
  <Accordion>
    {items.map((item) => (
      <AccordionItem key={item.value} value={item.value}>
        <AccordionTrigger>{item.title}</AccordionTrigger>
        <AccordionContent>{item.content}</AccordionContent>
      </AccordionItem>
    ))}
  </Accordion>
</div>
```

Give each `AccordionItem` a unique `value`. By default only one panel is open at a time; opening another closes the previous one. Set `multiple` on `Accordion` to let several panels stay open.

The open state is an array of item values. Use `defaultValue` (for example `["shipping"]`) for uncontrolled use, or `value` with `onValueChange` to control it.

## Examples

### Filled

The `filled` variant places the items on a rounded `bg-muted` surface.

**Example**

```tsx
import {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
} from "@/components/ui/accordion"

const items = [
  {
    value: "shipping",
    title: "How long does shipping take?",
    content:
      "Orders ship within two business days and arrive in three to five days.",
  },
  {
    value: "returns",
    title: "Can I return an item?",
    content:
      "Items can be returned within 14 days of delivery for a full refund.",
  },
  {
    value: "support",
    title: "How do I contact support?",
    content:
      "Reach the support team from the help center, available around the clock.",
  },
]

export default function AccordionFilled() {
  return (
    <div className="w-96">
      <Accordion variant="filled">
        {items.map((item) => (
          <AccordionItem key={item.value} value={item.value}>
            <AccordionTrigger>{item.title}</AccordionTrigger>
            <AccordionContent>{item.content}</AccordionContent>
          </AccordionItem>
        ))}
      </Accordion>
    </div>
  )
}
```

### By default

Opens an item on first render with `defaultValue`.

**Example**

```tsx
import {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
} from "@/components/ui/accordion"

const items = [
  {
    value: "shipping",
    title: "How long does shipping take?",
    content:
      "Orders ship within two business days and arrive in three to five days.",
  },
  {
    value: "returns",
    title: "Can I return an item?",
    content:
      "Items can be returned within 14 days of delivery for a full refund.",
  },
  {
    value: "support",
    title: "How do I contact support?",
    content:
      "Reach the support team from the help center, available around the clock.",
  },
]

export default function AccordionOpenByDefault() {
  return (
    <div className="w-96">
      <Accordion defaultValue={["shipping"]}>
        {items.map((item) => (
          <AccordionItem key={item.value} value={item.value}>
            <AccordionTrigger>{item.title}</AccordionTrigger>
            <AccordionContent>{item.content}</AccordionContent>
          </AccordionItem>
        ))}
      </Accordion>
    </div>
  )
}
```

### Multiple

Keeps several panels open at once with `multiple`.

**Example**

```tsx
import {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
} from "@/components/ui/accordion"

const items = [
  {
    value: "shipping",
    title: "How long does shipping take?",
    content:
      "Orders ship within two business days and arrive in three to five days.",
  },
  {
    value: "returns",
    title: "Can I return an item?",
    content:
      "Items can be returned within 14 days of delivery for a full refund.",
  },
  {
    value: "support",
    title: "How do I contact support?",
    content:
      "Reach the support team from the help center, available around the clock.",
  },
]

export default function AccordionMultiple() {
  return (
    <div className="w-96">
      <Accordion multiple defaultValue={["shipping", "returns"]}>
        {items.map((item) => (
          <AccordionItem key={item.value} value={item.value}>
            <AccordionTrigger>{item.title}</AccordionTrigger>
            <AccordionContent>{item.content}</AccordionContent>
          </AccordionItem>
        ))}
      </Accordion>
    </div>
  )
}
```

## API Reference

- `Accordion`
- `AccordionItem`
  - `AccordionTrigger`
  - `AccordionContent`

### Accordion

The root that groups items and manages which panels are open.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"default" \| "filled"` | `"default"` | Visual style. `default` is plain with dividers; `filled` adds a rounded `bg-muted` container. |

Also accepts the props of the primitive part it wraps.

### AccordionItem

A single section that pairs a trigger with its panel, identified by `value`.

Also accepts the props of the primitive part it wraps.

### AccordionTrigger

The button, inside a heading, that toggles its item's panel and shows the caret indicator.

Also accepts the props of the primitive part it wraps.

### AccordionContent

The collapsible panel holding the item's content.

Also accepts the props of the primitive part it wraps.

## Accessibility

- Each trigger is a `button` with `aria-expanded` reflecting its panel's state.
- Triggers are wrapped in a heading element.
- Triggers are reachable with Tab; `Enter` and `Space` toggle the focused panel.
- Disabled triggers receive `aria-disabled` and are dimmed with `opacity-50`.

## Notes

- Styling hooks: `data-slot="accordion"`, `"accordion-item"`, `"accordion-trigger"`, `"accordion-trigger-icon"` and `"accordion-content"`.
- The panel animates with `animate-accordion-down` and `animate-accordion-up` on `data-open` and `data-closed`.
- `className` on `AccordionContent` applies to the inner padded wrapper, not the animated panel.

**React Native, SwiftUI, Jetpack Compose**

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

```tsx
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Text } from "@/components/ui/text"

const items = [
  {
    value: "item-1",
    title: "Is it accessible?",
    content: "Yes. It works with VoiceOver and TalkBack.",
  },
  {
    value: "item-2",
    title: "Is it styled?",
    content:
      "Yes. It comes with default styles that match the other components.",
  },
  {
    value: "item-3",
    title: "Is it animated?",
    content: "Yes. It animates open and closed by default.",
  },
]

function AccordionItems() {
  return items.map((item) => (
    <AccordionItem key={item.value} value={item.value}>
      <AccordionTrigger>
        <Text>{item.title}</Text>
      </AccordionTrigger>
      <AccordionContent>
        <Text>{item.content}</Text>
      </AccordionContent>
    </AccordionItem>
  ))
}

export function AccordionOpenByDefaultExample() {
  return (
    <Accordion defaultValue={["item-2"]}>
      <AccordionItems />
    </Accordion>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct AccordionOpenByDefaultExample: View {
  var body: some View {
    AccordionDemo(initial: ["item-2"], multiple: false)
  }
}

struct AccordionEntry: Identifiable {
  let value: String
  let title: LocalizedStringKey
  let content: LocalizedStringKey

  var id: String { value }
}

let accordionEntries = [
  AccordionEntry(value: "item-1", title: "Is it accessible?", content: "Yes. It works with VoiceOver and TalkBack."),
  AccordionEntry(
    value: "item-2",
    title: "Is it styled?",
    content: "Yes. It comes with default styles that match the other components."
  ),
  AccordionEntry(value: "item-3", title: "Is it animated?", content: "Yes. It animates open and closed by default."),
]

struct AccordionItems: View {
  var body: some View {
    ForEach(accordionEntries) { entry in
      AccordionItem(entry.title, value: entry.value) {
        Text(entry.content)
      }
    }
  }
}

struct AccordionDemo: View {
  let multiple: Bool

  @State private var expanded: Set<String>

  init(initial: Set<String>, multiple: Bool) {
    self.multiple = multiple
    _expanded = State(initialValue: initial)
  }

  var body: some View {
    Accordion(expanded: $expanded, multiple: multiple) {
      AccordionItems()
    }
  }
}
```

```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.esuiss.ui.components.Accordion
import com.esuiss.ui.components.AccordionItem
import com.esuiss.ui.components.AccordionVariant
import com.esuiss.ui.components.Text

@Composable
fun AccordionOpenByDefaultExample() {
    AccordionSingle(AccordionVariant.Default, "item-2")
}

class AccordionEntry(val value: String, val title: String, val content: String)

val accordionEntries = listOf(
    AccordionEntry("item-1", "Is it accessible?", "Yes. It works with VoiceOver and TalkBack."),
    AccordionEntry("item-2", "Is it styled?", "Yes. It comes with default styles that match the other components."),
    AccordionEntry("item-3", "Is it animated?", "Yes. It animates open and closed by default."),
)

@Composable
fun AccordionItems(open: List<String>, onToggle: (String, Boolean) -> Unit) {
    accordionEntries.forEach { entry ->
        AccordionItem(
            expanded = entry.value in open,
            onExpandedChange = { onToggle(entry.value, it) },
            title = entry.title,
        ) {
            Text(entry.content)
        }
    }
}

@Composable
fun AccordionSingle(variant: AccordionVariant, defaultValue: String) {
    var open by rememberSaveable { mutableStateOf(defaultValue) }
    Accordion(variant = variant) {
        AccordionItems(listOf(open)) { value, expanded -> open = if (expanded) value else "" }
    }
}
```

`Accordion` groups `AccordionItem`s, each with an `AccordionTrigger` and an `AccordionContent` panel. The root and items are Reanimated views with a layout transition, so items below move smoothly as a panel opens or closes, and the trigger's caret rotates as it opens.

The `default` variant draws items on the page background separated by hairlines. The `filled` variant puts the whole list on a rounded `bg-muted` surface, which suits grouped settings or FAQ cards.

## Installation

**React Native**

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

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

**SwiftUI**

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

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add accordion --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-reanimated react-native-svg react-native-worklets
```

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/accordion.tsx`, `components/primitives/use-render.tsx`.

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

```tsx title="uniwind/components/ui/accordion.tsx"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { CaretDownIcon } from "phosphor-react-native"
import * as React from "react"
import type { GestureResponderEvent } from "react-native"
import Animated, {
  FadeOutUp,
  LayoutAnimationConfig,
  LinearTransition,
  ReduceMotion,
  useAnimatedStyle,
  useDerivedValue,
  withTiming,
} from "react-native-reanimated"
import * as AccordionPrimitive from "@/components/primitives/accordion"
import { resolveClassName } from "@/components/primitives/use-render"
import { Icon } from "@/components/ui/icon"
import { TextClassContext } from "@/components/ui/text"

const accordionVariants = cva("w-full flex-col", {
  variants: {
    variant: {
      default: "",
      filled: "overflow-hidden rounded-2xl bg-muted",
    },
  },
  defaultVariants: {
    variant: "default",
  },
})

function Accordion({
  className,
  variant,
  ...props
}: React.ComponentProps<typeof AccordionPrimitive.Root> &
  VariantProps<typeof accordionVariants>) {
  return (
    <LayoutAnimationConfig skipEntering>
      <AccordionPrimitive.Root
        className={(state) =>
          cn(accordionVariants({ variant }), resolveClassName(className, state))
        }
        render={<Animated.View layout={LinearTransition.duration(200)} />}
        {...props}
      />
    </LayoutAnimationConfig>
  )
}

function AccordionItem({
  className,
  ...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
  return (
    <AccordionPrimitive.Item
      className={(state) =>
        cn(
          "ms-4 overflow-hidden",
          state.index > 0 && "border-t border-border",
          resolveClassName(className, state)
        )
      }
      render={<Animated.View layout={LinearTransition.duration(200)} />}
      {...props}
    />
  )
}

function AccordionTrigger({
  className,
  children,
  onPressIn,
  onPressOut,
  ...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
  const { open, disabled } = AccordionPrimitive.useItemContext()
  const [pressed, setPressed] = React.useState(false)

  const progress = useDerivedValue(() =>
    open ? withTiming(1, { duration: 250 }) : withTiming(0, { duration: 200 })
  )
  const caretStyle = useAnimatedStyle(() => ({
    transform: [{ rotate: `${progress.value * 180}deg` }],
  }))

  return (
    <TextClassContext.Provider
      value={cn(
        "shrink text-left text-base",
        disabled ? "text-foreground opacity-50" : "text-foreground",
        pressed && !disabled && "underline"
      )}
    >
      <AccordionPrimitive.Header className="flex-row">
        <AccordionPrimitive.Trigger
          className={(state) =>
            cn(
              "min-h-11 flex-1 flex-row items-center justify-between gap-6 py-2.75 pe-4",
              resolveClassName(className, state)
            )
          }
          onPressIn={(event: GestureResponderEvent) => {
            setPressed(true)
            onPressIn?.(event)
          }}
          onPressOut={(event: GestureResponderEvent) => {
            setPressed(false)
            onPressOut?.(event)
          }}
          {...props}
        >
          {children}
          <Animated.View style={caretStyle}>
            <Icon
              as={CaretDownIcon}
              className="size-4 shrink-0 text-muted-foreground"
            />
          </Animated.View>
        </AccordionPrimitive.Trigger>
      </AccordionPrimitive.Header>
    </TextClassContext.Provider>
  )
}

function AccordionContent({
  className,
  children,
  ...props
}: Omit<React.ComponentProps<typeof AccordionPrimitive.Panel>, "className"> & {
  className?: string
}) {
  return (
    <TextClassContext.Provider value="text-sm text-muted-foreground">
      <AccordionPrimitive.Panel className="overflow-hidden" {...props}>
        <Animated.View
          exiting={FadeOutUp.duration(200).reduceMotion(ReduceMotion.System)}
          className={cn("gap-4 pe-10 pb-3", className)}
        >
          {children}
        </Animated.View>
      </AccordionPrimitive.Panel>
    </TextClassContext.Provider>
  )
}

export {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
  accordionVariants,
}
```

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-reanimated react-native-svg react-native-worklets
```

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/accordion.tsx`, `components/primitives/use-render.tsx`.

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

```tsx title="nativewind/components/ui/accordion.tsx"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { CaretDownIcon } from "phosphor-react-native"
import * as React from "react"
import type { GestureResponderEvent } from "react-native"
import Animated, {
  FadeOutUp,
  LayoutAnimationConfig,
  LinearTransition,
  ReduceMotion,
  useAnimatedStyle,
  useDerivedValue,
  withTiming,
} from "react-native-reanimated"
import * as AccordionPrimitive from "@/components/primitives/accordion"
import { resolveClassName } from "@/components/primitives/use-render"
import { Icon } from "@/components/ui/icon"
import { TextClassContext } from "@/components/ui/text"

const accordionVariants = cva("w-full flex-col", {
  variants: {
    variant: {
      default: "",
      filled: "overflow-hidden rounded-2xl bg-muted",
    },
  },
  defaultVariants: {
    variant: "default",
  },
})

function Accordion({
  className,
  variant,
  ...props
}: React.ComponentProps<typeof AccordionPrimitive.Root> &
  VariantProps<typeof accordionVariants>) {
  return (
    <LayoutAnimationConfig skipEntering>
      <AccordionPrimitive.Root
        className={(state) =>
          cn(accordionVariants({ variant }), resolveClassName(className, state))
        }
        render={<Animated.View layout={LinearTransition.duration(200)} />}
        {...props}
      />
    </LayoutAnimationConfig>
  )
}

function AccordionItem({
  className,
  ...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
  return (
    <AccordionPrimitive.Item
      className={(state) =>
        cn(
          "ms-4 overflow-hidden",
          state.index > 0 && "border-t border-border",
          resolveClassName(className, state)
        )
      }
      render={<Animated.View layout={LinearTransition.duration(200)} />}
      {...props}
    />
  )
}

function AccordionTrigger({
  className,
  children,
  onPressIn,
  onPressOut,
  ...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
  const { open, disabled } = AccordionPrimitive.useItemContext()
  const [pressed, setPressed] = React.useState(false)

  const progress = useDerivedValue(() =>
    open ? withTiming(1, { duration: 250 }) : withTiming(0, { duration: 200 })
  )
  const caretStyle = useAnimatedStyle(() => ({
    transform: [{ rotate: `${progress.value * 180}deg` }],
  }))

  return (
    <TextClassContext.Provider
      value={cn(
        "shrink text-left text-base",
        disabled ? "text-foreground opacity-50" : "text-foreground",
        pressed && !disabled && "underline"
      )}
    >
      <AccordionPrimitive.Header className="flex-row">
        <AccordionPrimitive.Trigger
          className={(state) =>
            cn(
              "min-h-11 flex-1 flex-row items-center justify-between gap-6 py-[0.6875rem] pe-4",
              resolveClassName(className, state)
            )
          }
          onPressIn={(event: GestureResponderEvent) => {
            setPressed(true)
            onPressIn?.(event)
          }}
          onPressOut={(event: GestureResponderEvent) => {
            setPressed(false)
            onPressOut?.(event)
          }}
          {...props}
        >
          {children}
          <Animated.View style={caretStyle}>
            <Icon
              as={CaretDownIcon}
              className="size-4 shrink-0 text-muted-foreground"
            />
          </Animated.View>
        </AccordionPrimitive.Trigger>
      </AccordionPrimitive.Header>
    </TextClassContext.Provider>
  )
}

function AccordionContent({
  className,
  children,
  ...props
}: Omit<React.ComponentProps<typeof AccordionPrimitive.Panel>, "className"> & {
  className?: string
}) {
  return (
    <TextClassContext.Provider value="text-sm text-muted-foreground">
      <AccordionPrimitive.Panel className="overflow-hidden" {...props}>
        <Animated.View
          exiting={FadeOutUp.duration(200).reduceMotion(ReduceMotion.System)}
          className={cn("gap-4 pe-10 pb-3", className)}
        >
          {children}
        </Animated.View>
      </AccordionPrimitive.Panel>
    </TextClassContext.Provider>
  )
}

export {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
  accordionVariants,
}
```

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-reanimated react-native-svg react-native-worklets
```

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/accordion.tsx`, `components/primitives/use-render.tsx`.

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

```tsx title="stylesheet/components/ui/accordion.tsx"
import { CaretDownIcon } from "phosphor-react-native"
import * as React from "react"
import {
  type GestureResponderEvent,
  type StyleProp,
  StyleSheet,
  type ViewStyle,
} from "react-native"
import Animated, {
  FadeOutUp,
  LayoutAnimationConfig,
  LinearTransition,
  ReduceMotion,
  useAnimatedStyle,
  useDerivedValue,
  withTiming,
} from "react-native-reanimated"
import * as AccordionPrimitive from "@/components/primitives/accordion"
import { Icon } from "@/components/ui/icon"
import { TextStyleContext } from "@/components/ui/text"
import { fontSize, radius, useTheme } from "@/components/ui/theme"

type AccordionVariant = "default" | "filled"

const styles = StyleSheet.create({
  root: { width: "100%", flexDirection: "column" },
  filled: { overflow: "hidden", borderRadius: radius["2xl"] },
  item: { marginStart: 16, overflow: "hidden" },
  itemSeparated: { borderTopWidth: 1 },
  header: { flexDirection: "row" },
  trigger: {
    minHeight: 44,
    flex: 1,
    flexDirection: "row",
    alignItems: "center",
    justifyContent: "space-between",
    gap: 24,
    paddingVertical: 11,
    paddingEnd: 16,
  },
  icon: { flexShrink: 0 },
  content: { overflow: "hidden" },
  contentInner: { gap: 16, paddingEnd: 40, paddingBottom: 12 },
})

function Accordion({
  style,
  variant = "default",
  ...props
}: React.ComponentProps<typeof AccordionPrimitive.Root> & {
  variant?: AccordionVariant
}) {
  const { colors } = useTheme()
  return (
    <LayoutAnimationConfig skipEntering>
      <AccordionPrimitive.Root
        style={(state) => [
          styles.root,
          variant === "filled" && [
            styles.filled,
            { backgroundColor: colors.muted },
          ],
          typeof style === "function" ? style(state) : style,
        ]}
        render={<Animated.View layout={LinearTransition.duration(200)} />}
        {...props}
      />
    </LayoutAnimationConfig>
  )
}

function AccordionItem({
  style,
  ...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
  const { colors } = useTheme()
  return (
    <AccordionPrimitive.Item
      style={(state) => [
        styles.item,
        state.index > 0 && [
          styles.itemSeparated,
          { borderColor: colors.border },
        ],
        typeof style === "function" ? style(state) : style,
      ]}
      render={<Animated.View layout={LinearTransition.duration(200)} />}
      {...props}
    />
  )
}

function AccordionTrigger({
  style,
  children,
  onPressIn,
  onPressOut,
  ...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
  const { colors } = useTheme()
  const { open, disabled } = AccordionPrimitive.useItemContext()
  const [pressed, setPressed] = React.useState(false)

  const progress = useDerivedValue(() =>
    open ? withTiming(1, { duration: 250 }) : withTiming(0, { duration: 200 })
  )
  const caretStyle = useAnimatedStyle(() => ({
    transform: [{ rotate: `${progress.value * 180}deg` }],
  }))

  return (
    <TextStyleContext.Provider
      value={{
        ...fontSize.base,
        flexShrink: 1,
        textAlign: "left",
        color: colors.foreground,
        textDecorationLine: pressed && !disabled ? "underline" : "none",
      }}
    >
      <AccordionPrimitive.Header style={styles.header}>
        <AccordionPrimitive.Trigger
          style={(state) => [
            styles.trigger,
            disabled && { opacity: 0.5 },
            typeof style === "function" ? style(state) : style,
          ]}
          onPressIn={(event: GestureResponderEvent) => {
            setPressed(true)
            onPressIn?.(event)
          }}
          onPressOut={(event: GestureResponderEvent) => {
            setPressed(false)
            onPressOut?.(event)
          }}
          {...props}
        >
          {children}
          <Animated.View style={caretStyle}>
            <Icon
              as={CaretDownIcon}
              size={16}
              color={colors.mutedForeground}
              style={styles.icon}
            />
          </Animated.View>
        </AccordionPrimitive.Trigger>
      </AccordionPrimitive.Header>
    </TextStyleContext.Provider>
  )
}

function AccordionContent({
  style,
  children,
  ...props
}: Omit<React.ComponentProps<typeof AccordionPrimitive.Panel>, "style"> & {
  style?: StyleProp<ViewStyle>
}) {
  const { colors } = useTheme()
  return (
    <TextStyleContext.Provider
      value={{ ...fontSize.sm, color: colors.mutedForeground }}
    >
      <AccordionPrimitive.Panel style={styles.content} {...props}>
        <Animated.View
          exiting={FadeOutUp.duration(200).reduceMotion(ReduceMotion.System)}
          style={[styles.contentInner, style]}
        >
          {children}
        </Animated.View>
      </AccordionPrimitive.Panel>
    </TextStyleContext.Provider>
  )
}

export type { AccordionVariant }
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger }
```

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:

```swift title="swift/Components/Accordion.swift"
import PhosphorSwift
import SwiftUI

public struct Accordion<Content: View>: View {
  public enum Variant: CaseIterable, Sendable {
    case `default`, filled
  }

  let variant: Variant
  let multiple: Bool
  let external: Binding<Set<AnyHashable>>?
  let content: Content

  @State private var local: Set<AnyHashable> = []
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init(
    _ variant: Variant = .default,
    multiple: Bool = false,
    @ViewBuilder content: () -> Content
  ) {
    self.variant = variant
    self.multiple = multiple
    self.external = nil
    self.content = content()
  }

  public init<Value: Hashable & Sendable>(
    _ variant: Variant = .default,
    expanded: Binding<Set<Value>>,
    multiple: Bool = false,
    @ViewBuilder content: () -> Content
  ) {
    self.variant = variant
    self.multiple = multiple
    self.external = Binding(
      get: { Set(expanded.wrappedValue.map { AnyHashable($0) }) },
      set: { expanded.wrappedValue = Set($0.compactMap { $0.base as? Value }) }
    )
    self.content = content()
  }

  public init<Value: Hashable & Sendable>(
    _ variant: Variant = .default,
    expanded: Binding<Value?>,
    @ViewBuilder content: () -> Content
  ) {
    self.variant = variant
    self.multiple = false
    self.external = Binding(
      get: { expanded.wrappedValue.map { [AnyHashable($0)] } ?? [] },
      set: { expanded.wrappedValue = $0.first?.base as? Value }
    )
    self.content = content()
  }

  public var body: some View {
    let shape = RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous)
    VStack(alignment: .leading, spacing: 0) {
      content
    }
    .padding(.top, -1)
    .frame(maxWidth: .infinity, alignment: .leading)
    .clipped()
    .background(variant == .filled ? EsuissColor.muted : .clear, in: shape)
    .clipShape(variant == .filled ? AnyShape(shape) : AnyShape(Rectangle()))
    .environment(\.accordion, context)
  }

  private var context: AccordionContext {
    let binding = external ?? $local
    let multiple = multiple
    let animation: Animation? = reduceMotion ? .linear(duration: 0.15) : .easeInOut(duration: 0.2)
    return AccordionContext(
      expanded: binding.wrappedValue,
      toggle: { value in
        withAnimation(animation) {
          var next = binding.wrappedValue
          if next.contains(value) {
            next.remove(value)
          } else if multiple {
            next.insert(value)
          } else {
            next = [value]
          }
          binding.wrappedValue = next
        }
      }
    )
  }
}

struct AccordionContext: @unchecked Sendable {
  var expanded: Set<AnyHashable> = []
  var toggle: @MainActor @Sendable (AnyHashable) -> Void = { _ in }
}

extension EnvironmentValues {
  @Entry var accordion = AccordionContext()
}

public struct AccordionItem<Label: View, Content: View>: View {
  let value: AnyHashable
  let label: Label
  let content: Content

  @Environment(\.accordion) private var accordion
  @Environment(\.isEnabled) private var isEnabled
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init<Value: Hashable & Sendable>(
    value: Value,
    @ViewBuilder content: () -> Content,
    @ViewBuilder label: () -> Label
  ) {
    self.value = AnyHashable(value)
    self.label = label()
    self.content = content()
  }

  public var body: some View {
    let open = accordion.expanded.contains(value)
    VStack(alignment: .leading, spacing: 0) {
      Button {
        accordion.toggle(value)
      } label: {
        HStack(spacing: 24) {
          label
            .textStyle(.base)
            .multilineTextAlignment(.leading)
            .foregroundStyle(EsuissColor.foreground)
            .frame(maxWidth: .infinity, alignment: .leading)
          Ph.caretDown.regular
            .frame(width: 16, height: 16)
            .foregroundStyle(EsuissColor.mutedForeground)
            .rotationEffect(.degrees(open ? 180 : 0))
            .animation(.easeInOut(duration: open ? 0.25 : 0.2), value: open)
        }
        .padding(.vertical, 11)
        .padding(.trailing, 16)
        .frame(minHeight: 44)
        .contentShape(Rectangle())
      }
      .buttonStyle(AccordionTriggerStyle())
      .accessibilityAddTraits(.isHeader)
      .accessibilityValue(Text(open ? "Expanded" : "Collapsed"))
      if open {
        VStack(alignment: .leading, spacing: 16) {
          content
        }
        .textStyle(.sm)
        .foregroundStyle(EsuissColor.mutedForeground)
        .fixedSize(horizontal: false, vertical: true)
        .padding(.trailing, 40)
        .padding(.bottom, 12)
        .frame(maxWidth: .infinity, alignment: .leading)
        .transition(reduceMotion ? .opacity : .opacity.combined(with: .offset(y: -8)))
      }
    }
    .padding(.leading, 16)
    .overlay(alignment: .top) {
      Rectangle()
        .fill(EsuissColor.border)
        .frame(height: 1)
        .padding(.leading, 16)
    }
  }
}

extension AccordionItem where Label == Text {
  public init<Value: Hashable & Sendable>(
    _ title: LocalizedStringKey,
    value: Value,
    @ViewBuilder content: () -> Content
  ) {
    self.init(value: value, content: content) { Text(title) }
  }
}

private struct AccordionTriggerStyle: ButtonStyle {
  @Environment(\.isEnabled) private var isEnabled

  func makeBody(configuration: Configuration) -> some View {
    configuration.label
      .underline(configuration.isPressed && isEnabled)
      .opacity(isEnabled ? 1 : 0.5)
  }
}
```

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/Accordion.kt"
package com.esuiss.ui.components

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
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.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.collapse
import androidx.compose.ui.semantics.expand
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.CaretDown
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography

enum class AccordionVariant { Default, Filled }

private class AccordionDividers {
    var positions by mutableStateOf(emptyList<Int>())
}

@Composable
fun Accordion(
    modifier: Modifier = Modifier,
    variant: AccordionVariant = AccordionVariant.Default,
    content: @Composable () -> Unit,
) {
    val colors = EsuissTheme.colors
    val dividers = remember { AccordionDividers() }
    val shape = RoundedCornerShape(Radius.xl2)
    Layout(
        content = content,
        modifier = modifier
            .fillMaxWidth()
            .then(
                if (variant == AccordionVariant.Filled) {
                    Modifier.clip(shape).background(colors.muted, shape)
                } else {
                    Modifier
                },
            )
            .drawWithContent {
                drawContent()
                val inset = 16.dp.toPx()
                val stroke = 1.dp.toPx()
                val rtl = layoutDirection == LayoutDirection.Rtl
                dividers.positions.forEach { y ->
                    val top = y + stroke / 2
                    drawLine(
                        color = colors.border,
                        start = Offset(if (rtl) 0f else inset, top),
                        end = Offset(if (rtl) size.width - inset else size.width, top),
                        strokeWidth = stroke,
                    )
                }
            },
    ) { measurables, constraints ->
        val placeables = measurables.map { it.measure(constraints.copy(minHeight = 0)) }
        val width = constraints.maxWidth
        val height = placeables.sumOf { it.height }.coerceIn(constraints.minHeight, constraints.maxHeight)
        layout(width, height) {
            var y = 0
            val positions = ArrayList<Int>()
            placeables.forEachIndexed { index, placeable ->
                if (index > 0) positions.add(y)
                placeable.placeRelative(0, y)
                y += placeable.height
            }
            if (dividers.positions != positions) dividers.positions = positions
        }
    }
}

@Composable
fun AccordionItem(
    expanded: Boolean,
    onExpandedChange: (Boolean) -> Unit,
    title: String,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    content: @Composable ColumnScope.() -> Unit,
) {
    AccordionItem(
        expanded = expanded,
        onExpandedChange = onExpandedChange,
        modifier = modifier,
        enabled = enabled,
        trigger = { BasicText(title, style = LocalContentStyle.current) },
        content = content,
    )
}

@Composable
fun AccordionItem(
    expanded: Boolean,
    onExpandedChange: (Boolean) -> Unit,
    trigger: @Composable RowScope.() -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    content: @Composable ColumnScope.() -> Unit,
) {
    Column(modifier = modifier.fillMaxWidth().padding(start = 16.dp)) {
        AccordionTrigger(expanded, onExpandedChange, enabled, trigger)
        AnimatedVisibility(
            visible = expanded,
            enter = expandVertically(tween(250), expandFrom = Alignment.Top) + fadeIn(tween(250)),
            exit = shrinkVertically(tween(200), shrinkTowards = Alignment.Top) + fadeOut(tween(200)),
        ) {
            AccordionContent(content)
        }
    }
}

@Composable
private fun AccordionTrigger(
    expanded: Boolean,
    onExpandedChange: (Boolean) -> Unit,
    enabled: Boolean,
    trigger: @Composable RowScope.() -> Unit,
) {
    val colors = EsuissTheme.colors
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val rotation by animateFloatAsState(
        targetValue = if (expanded) 180f else 0f,
        animationSpec = tween(if (expanded) 250 else 200),
        label = "caret",
    )
    val foreground = colors.foreground
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .heightIn(min = 44.dp)
            .alpha(if (enabled) 1f else 0.5f)
            .semantics {
                heading()
                stateDescription = if (expanded) "Expanded" else "Collapsed"
                if (enabled) {
                    if (expanded) {
                        collapse { onExpandedChange(false); true }
                    } else {
                        expand { onExpandedChange(true); true }
                    }
                }
            }
            .clickable(
                interactionSource = interaction,
                indication = null,
                enabled = enabled,
                role = Role.Button,
                onClick = { onExpandedChange(!expanded) },
            )
            .padding(top = 11.dp, bottom = 11.dp, end = 16.dp),
        horizontalArrangement = Arrangement.spacedBy(24.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        CompositionLocalProvider(
            LocalContentStyle provides Typography.base.copy(
                color = foreground,
                textDecoration = if (pressed && enabled) TextDecoration.Underline else null,
            ),
            LocalContentColor provides foreground,
            LocalIconSize provides 16.dp,
        ) {
            Row(
                modifier = Modifier.weight(1f),
                horizontalArrangement = Arrangement.spacedBy(8.dp),
                verticalAlignment = Alignment.CenterVertically,
                content = trigger,
            )
        }
        Icon(
            PhosphorIcons.Regular.CaretDown,
            contentDescription = null,
            modifier = Modifier.rotate(rotation),
            size = 16.dp,
            tint = colors.mutedForeground,
        )
    }
}

@Composable
private fun AccordionContent(content: @Composable ColumnScope.() -> Unit) {
    val colors = EsuissTheme.colors
    CompositionLocalProvider(
        LocalContentStyle provides Typography.sm.copy(color = colors.mutedForeground),
        LocalContentColor provides colors.mutedForeground,
    ) {
        Column(
            modifier = Modifier.fillMaxWidth().padding(end = 40.dp, bottom = 12.dp),
            verticalArrangement = Arrangement.spacedBy(16.dp),
            content = content,
        )
    }
}
```

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

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

## Usage

```tsx
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from "@/components/ui/accordion"
```

```tsx
<Accordion defaultValue={["item-2"]}>
  <AccordionItems />
</Accordion>
```

Give each `AccordionItem` a `value`. By default only one item is open at a time; set `multiple` to let several stay open.

Use `defaultValue` (an array of item values) for uncontrolled state, or `value` with `onValueChange` to control it. Set `disabled` on the root or on a single item to block toggling, and `keepMounted` to keep closed panels mounted.

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

## Examples

### Default

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

```tsx
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Text } from "@/components/ui/text"

const items = [
  {
    value: "item-1",
    title: "Is it accessible?",
    content: "Yes. It works with VoiceOver and TalkBack.",
  },
  {
    value: "item-2",
    title: "Is it styled?",
    content:
      "Yes. It comes with default styles that match the other components.",
  },
  {
    value: "item-3",
    title: "Is it animated?",
    content: "Yes. It animates open and closed by default.",
  },
]

function AccordionItems() {
  return items.map((item) => (
    <AccordionItem key={item.value} value={item.value}>
      <AccordionTrigger>
        <Text>{item.title}</Text>
      </AccordionTrigger>
      <AccordionContent>
        <Text>{item.content}</Text>
      </AccordionContent>
    </AccordionItem>
  ))
}

export function AccordionExample() {
  return (
    <Accordion>
      <AccordionItems />
    </Accordion>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct AccordionExample: View {
  var body: some View {
    Accordion {
      AccordionItems()
    }
  }
}

struct AccordionEntry: Identifiable {
  let value: String
  let title: LocalizedStringKey
  let content: LocalizedStringKey

  var id: String { value }
}

let accordionEntries = [
  AccordionEntry(value: "item-1", title: "Is it accessible?", content: "Yes. It works with VoiceOver and TalkBack."),
  AccordionEntry(
    value: "item-2",
    title: "Is it styled?",
    content: "Yes. It comes with default styles that match the other components."
  ),
  AccordionEntry(value: "item-3", title: "Is it animated?", content: "Yes. It animates open and closed by default."),
]

struct AccordionItems: View {
  var body: some View {
    ForEach(accordionEntries) { entry in
      AccordionItem(entry.title, value: entry.value) {
        Text(entry.content)
      }
    }
  }
}
```

```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.esuiss.ui.components.Accordion
import com.esuiss.ui.components.AccordionItem
import com.esuiss.ui.components.AccordionVariant
import com.esuiss.ui.components.Text

@Composable
fun AccordionExample() {
    AccordionSingle(AccordionVariant.Default, "")
}

class AccordionEntry(val value: String, val title: String, val content: String)

val accordionEntries = listOf(
    AccordionEntry("item-1", "Is it accessible?", "Yes. It works with VoiceOver and TalkBack."),
    AccordionEntry("item-2", "Is it styled?", "Yes. It comes with default styles that match the other components."),
    AccordionEntry("item-3", "Is it animated?", "Yes. It animates open and closed by default."),
)

@Composable
fun AccordionItems(open: List<String>, onToggle: (String, Boolean) -> Unit) {
    accordionEntries.forEach { entry ->
        AccordionItem(
            expanded = entry.value in open,
            onExpandedChange = { onToggle(entry.value, it) },
            title = entry.title,
        ) {
            Text(entry.content)
        }
    }
}

@Composable
fun AccordionSingle(variant: AccordionVariant, defaultValue: String) {
    var open by rememberSaveable { mutableStateOf(defaultValue) }
    Accordion(variant = variant) {
        AccordionItems(listOf(open)) { value, expanded -> open = if (expanded) value else "" }
    }
}
```

### Filled

The `filled` variant on a rounded `bg-muted` surface.

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

```tsx
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Text } from "@/components/ui/text"

const items = [
  {
    value: "item-1",
    title: "Is it accessible?",
    content: "Yes. It works with VoiceOver and TalkBack.",
  },
  {
    value: "item-2",
    title: "Is it styled?",
    content:
      "Yes. It comes with default styles that match the other components.",
  },
  {
    value: "item-3",
    title: "Is it animated?",
    content: "Yes. It animates open and closed by default.",
  },
]

function AccordionItems() {
  return items.map((item) => (
    <AccordionItem key={item.value} value={item.value}>
      <AccordionTrigger>
        <Text>{item.title}</Text>
      </AccordionTrigger>
      <AccordionContent>
        <Text>{item.content}</Text>
      </AccordionContent>
    </AccordionItem>
  ))
}

export function AccordionFilledExample() {
  return (
    <Accordion variant="filled">
      <AccordionItems />
    </Accordion>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct AccordionFilledExample: View {
  var body: some View {
    Accordion(.filled) {
      AccordionItems()
    }
  }
}

struct AccordionEntry: Identifiable {
  let value: String
  let title: LocalizedStringKey
  let content: LocalizedStringKey

  var id: String { value }
}

let accordionEntries = [
  AccordionEntry(value: "item-1", title: "Is it accessible?", content: "Yes. It works with VoiceOver and TalkBack."),
  AccordionEntry(
    value: "item-2",
    title: "Is it styled?",
    content: "Yes. It comes with default styles that match the other components."
  ),
  AccordionEntry(value: "item-3", title: "Is it animated?", content: "Yes. It animates open and closed by default."),
]

struct AccordionItems: View {
  var body: some View {
    ForEach(accordionEntries) { entry in
      AccordionItem(entry.title, value: entry.value) {
        Text(entry.content)
      }
    }
  }
}
```

```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.esuiss.ui.components.Accordion
import com.esuiss.ui.components.AccordionItem
import com.esuiss.ui.components.AccordionVariant
import com.esuiss.ui.components.Text

@Composable
fun AccordionFilledExample() {
    AccordionSingle(AccordionVariant.Filled, "")
}

class AccordionEntry(val value: String, val title: String, val content: String)

val accordionEntries = listOf(
    AccordionEntry("item-1", "Is it accessible?", "Yes. It works with VoiceOver and TalkBack."),
    AccordionEntry("item-2", "Is it styled?", "Yes. It comes with default styles that match the other components."),
    AccordionEntry("item-3", "Is it animated?", "Yes. It animates open and closed by default."),
)

@Composable
fun AccordionItems(open: List<String>, onToggle: (String, Boolean) -> Unit) {
    accordionEntries.forEach { entry ->
        AccordionItem(
            expanded = entry.value in open,
            onExpandedChange = { onToggle(entry.value, it) },
            title = entry.title,
        ) {
            Text(entry.content)
        }
    }
}

@Composable
fun AccordionSingle(variant: AccordionVariant, defaultValue: String) {
    var open by rememberSaveable { mutableStateOf(defaultValue) }
    Accordion(variant = variant) {
        AccordionItems(listOf(open)) { value, expanded -> open = if (expanded) value else "" }
    }
}
```

### Multiple

A controlled accordion with `multiple`, so several items stay open.

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

```tsx
import * as React from "react"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Text } from "@/components/ui/text"

const items = [
  {
    value: "item-1",
    title: "Is it accessible?",
    content: "Yes. It works with VoiceOver and TalkBack.",
  },
  {
    value: "item-2",
    title: "Is it styled?",
    content:
      "Yes. It comes with default styles that match the other components.",
  },
  {
    value: "item-3",
    title: "Is it animated?",
    content: "Yes. It animates open and closed by default.",
  },
]

function AccordionItems() {
  return items.map((item) => (
    <AccordionItem key={item.value} value={item.value}>
      <AccordionTrigger>
        <Text>{item.title}</Text>
      </AccordionTrigger>
      <AccordionContent>
        <Text>{item.content}</Text>
      </AccordionContent>
    </AccordionItem>
  ))
}

function MultipleDemo() {
  const [value, setValue] = React.useState<string[]>(["item-1", "item-3"])
  return (
    <Accordion multiple value={value} onValueChange={setValue}>
      <AccordionItems />
    </Accordion>
  )
}

export function AccordionMultipleExample() {
  return (
    <MultipleDemo />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct AccordionMultipleExample: View {
  var body: some View {
    AccordionDemo(initial: ["item-1", "item-3"], multiple: true)
  }
}

struct AccordionEntry: Identifiable {
  let value: String
  let title: LocalizedStringKey
  let content: LocalizedStringKey

  var id: String { value }
}

let accordionEntries = [
  AccordionEntry(value: "item-1", title: "Is it accessible?", content: "Yes. It works with VoiceOver and TalkBack."),
  AccordionEntry(
    value: "item-2",
    title: "Is it styled?",
    content: "Yes. It comes with default styles that match the other components."
  ),
  AccordionEntry(value: "item-3", title: "Is it animated?", content: "Yes. It animates open and closed by default."),
]

struct AccordionItems: View {
  var body: some View {
    ForEach(accordionEntries) { entry in
      AccordionItem(entry.title, value: entry.value) {
        Text(entry.content)
      }
    }
  }
}

struct AccordionDemo: View {
  let multiple: Bool

  @State private var expanded: Set<String>

  init(initial: Set<String>, multiple: Bool) {
    self.multiple = multiple
    _expanded = State(initialValue: initial)
  }

  var body: some View {
    Accordion(expanded: $expanded, multiple: multiple) {
      AccordionItems()
    }
  }
}
```

```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.esuiss.ui.components.Accordion
import com.esuiss.ui.components.AccordionItem
import com.esuiss.ui.components.Text

class AccordionEntry(val value: String, val title: String, val content: String)

val accordionEntries = listOf(
    AccordionEntry("item-1", "Is it accessible?", "Yes. It works with VoiceOver and TalkBack."),
    AccordionEntry("item-2", "Is it styled?", "Yes. It comes with default styles that match the other components."),
    AccordionEntry("item-3", "Is it animated?", "Yes. It animates open and closed by default."),
)

@Composable
fun AccordionItems(open: List<String>, onToggle: (String, Boolean) -> Unit) {
    accordionEntries.forEach { entry ->
        AccordionItem(
            expanded = entry.value in open,
            onExpandedChange = { onToggle(entry.value, it) },
            title = entry.title,
        ) {
            Text(entry.content)
        }
    }
}

@Composable
fun AccordionMultiple() {
    var open by rememberSaveable { mutableStateOf("item-1,item-3") }
    Accordion {
        AccordionItems(open.split(",")) { value, expanded ->
            val current = open.split(",").filter { it.isNotEmpty() }
            open = (if (expanded) current + value else current - value).joinToString(",")
        }
    }
}
```

## API Reference

### Accordion

Root that holds the open state and lays out the items.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"default" \| "filled"` | `"default"` | `default` or `filled`. |

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

### AccordionItem

One section, separated from the previous one by a hairline.

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

### AccordionTrigger

Pressable heading row with a rotating caret.

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

### AccordionContent

Panel shown while the item is open; fades out upward on close.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `className` | `string` | - |  |

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

## Accessibility

- The header row has the `header` role, and the trigger has the `button` role with `expanded` and `disabled` accessibility state.
- Closed panels are hidden from VoiceOver and TalkBack.
- A disabled trigger is shown at 50% opacity.

## Notes

- The trigger label underlines while pressed.
- Animations respect the system Reduce Motion setting.
- Uses `bg-muted`, `border-border` and `text-foreground` tokens; light and dark follow the system appearance.

