# Slider

A slider for picking a single value or a range by dragging one or more thumbs along a track.

Slider is an open-source mobile component from suiss/uim for React Native and Expo, with SwiftUI and Jetpack Compose versions built from the same design tokens. The @esuiss/uim CLI copies its source into your project, so you own the code.

Source: https://uim.suiss.app/docs/components/slider

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

```tsx
import { Slider } from "@/components/ui/slider"

export function SliderExample() {
  return (
    <Slider defaultValue={50} max={100} step={1} accessibilityLabel="Volume" />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct SliderExample: View {
  var body: some View {
    SliderDemo(initial: 50)
  }
}

struct SliderDemo: View {
  var bounds: ClosedRange<Double> = 0...100
  var step: Double = 1

  @State private var value: Double

  init(initial: Double, bounds: ClosedRange<Double> = 0...100, step: Double = 1) {
    self.bounds = bounds
    self.step = step
    _value = State(initialValue: initial)
  }

  var body: some View {
    ValueSlider(value: $value, in: bounds, step: step)
      .accessibilityLabel(Text("Volume"))
      .frame(maxWidth: .infinity)
  }
}
```

```kotlin
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import com.esuiss.ui.components.Slider

@Composable
fun SliderExample() {
    SliderUncontrolled(50f)
}

@Composable
fun SliderUncontrolled(
    initial: Float,
    valueRange: ClosedFloatingPointRange<Float> = 0f..100f,
    step: Float = 1f,
    enabled: Boolean = true,
) {
    var value by rememberSaveable { mutableFloatStateOf(initial) }
    Slider(
        value,
        { value = it },
        Modifier.fillMaxWidth(),
        enabled = enabled,
        valueRange = valueRange,
        step = step,
        contentDescription = "Volume",
    )
}
```

`Slider` renders a thin track, a filled indicator and one thumb per value. Pass a number for a single value, or an array for a range or several thumbs. The active thumb widens while it is dragged. It works horizontally or vertically.

Thumbs are aligned to the edges of the track, so they stay within it at `min` and `max`.

## Installation

```bash title="Terminal"
npx @esuiss/uim@latest add slider
```

```bash title="Terminal"
npx @esuiss/uim@latest add slider --platform swift
```

```bash title="Terminal"
npx @esuiss/uim@latest add slider --platform kotlin
```

### Manual

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn
```

2. Add the pieces it builds on: the primitives `components/primitives/slider.tsx`, `components/primitives/use-render.tsx`.

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

```tsx title="uniwind/components/ui/slider.tsx"
import { cn } from "cn"
import * as SliderPrimitive from "@/components/primitives/slider"
import { resolveClassName } from "@/components/primitives/use-render"

function thumbIndexes(
  value: SliderPrimitive.SliderValue | undefined,
  defaultValue: SliderPrimitive.SliderValue | undefined
) {
  const source = Array.isArray(value) ? value : defaultValue
  const count = Array.isArray(source) ? source.length : 1
  return Array.from({ length: count }, (_, index) => index)
}

function Slider<
  Value extends SliderPrimitive.SliderValue = SliderPrimitive.SliderValue,
>({
  className,
  defaultValue,
  value,
  min = 0,
  max = 100,
  accessibilityLabel,
  ...props
}: SliderPrimitive.RootProps<Value>) {
  return (
    <SliderPrimitive.Root
      className={(state) =>
        cn(
          state.orientation === "vertical" ? "h-full" : "w-full",
          resolveClassName(className, state)
        )
      }
      defaultValue={defaultValue}
      value={value}
      min={min}
      max={max}
      thumbAlignment="edge"
      {...props}
    >
      <SliderPrimitive.Control
        className={(state) =>
          cn(
            "relative items-center justify-center",
            state.orientation === "vertical"
              ? "h-full min-h-40 w-8 flex-col"
              : "h-8 w-full flex-row"
          )
        }
      >
        <SliderPrimitive.Track
          className={(state) =>
            cn(
              "relative grow overflow-hidden rounded-full",
              state.disabled ? "bg-control-disabled" : "bg-label-quaternary",
              state.orientation === "vertical" ? "h-full w-1" : "h-1 w-full"
            )
          }
        >
          <SliderPrimitive.Indicator
            className={(state) =>
              state.disabled ? "bg-accent-disabled" : "bg-accent"
            }
          />
        </SliderPrimitive.Track>
        {thumbIndexes(value, defaultValue).map((index) => (
          <SliderPrimitive.Thumb
            key={index}
            index={index}
            getAccessibilityLabel={
              accessibilityLabel ? () => accessibilityLabel : undefined
            }
            className={(state) =>
              cn(
                "rounded-full border border-label/10 bg-surface shadow-md dark:bg-label",
                state.orientation === "vertical"
                  ? state.dragging && state.active
                    ? "h-9 w-5"
                    : "h-8 w-5"
                  : state.dragging && state.active
                    ? "h-5 w-9"
                    : "h-5 w-8",
                state.disabled && "shadow-none"
              )
            }
          />
        ))}
      </SliderPrimitive.Control>
    </SliderPrimitive.Root>
  )
}

export { Slider }
```

4. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn
```

2. Add the pieces it builds on: the primitives `components/primitives/slider.tsx`, `components/primitives/use-render.tsx`.

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

```tsx title="nativewind/components/ui/slider.tsx"
import { cn } from "cn"
import * as SliderPrimitive from "@/components/primitives/slider"
import { resolveClassName } from "@/components/primitives/use-render"

function thumbIndexes(
  value: SliderPrimitive.SliderValue | undefined,
  defaultValue: SliderPrimitive.SliderValue | undefined
) {
  const source = Array.isArray(value) ? value : defaultValue
  const count = Array.isArray(source) ? source.length : 1
  return Array.from({ length: count }, (_, index) => index)
}

function Slider<
  Value extends SliderPrimitive.SliderValue = SliderPrimitive.SliderValue,
>({
  className,
  defaultValue,
  value,
  min = 0,
  max = 100,
  accessibilityLabel,
  ...props
}: SliderPrimitive.RootProps<Value>) {
  return (
    <SliderPrimitive.Root
      className={(state) =>
        cn(
          state.orientation === "vertical" ? "h-full" : "w-full",
          resolveClassName(className, state)
        )
      }
      defaultValue={defaultValue}
      value={value}
      min={min}
      max={max}
      thumbAlignment="edge"
      {...props}
    >
      <SliderPrimitive.Control
        className={(state) =>
          cn(
            "relative items-center justify-center",
            state.orientation === "vertical"
              ? "h-full min-h-40 w-8 flex-col"
              : "h-8 w-full flex-row"
          )
        }
      >
        <SliderPrimitive.Track
          className={(state) =>
            cn(
              "relative grow overflow-hidden rounded-full",
              state.disabled ? "bg-control-disabled" : "bg-label-quaternary",
              state.orientation === "vertical" ? "h-full w-1" : "h-1 w-full"
            )
          }
        >
          <SliderPrimitive.Indicator
            className={(state) =>
              state.disabled ? "bg-accent-disabled" : "bg-accent"
            }
          />
        </SliderPrimitive.Track>
        {thumbIndexes(value, defaultValue).map((index) => (
          <SliderPrimitive.Thumb
            key={index}
            index={index}
            getAccessibilityLabel={
              accessibilityLabel ? () => accessibilityLabel : undefined
            }
            className={(state) =>
              cn(
                "rounded-full border border-label-10 bg-surface shadow-md dark:bg-label",
                state.orientation === "vertical"
                  ? state.dragging && state.active
                    ? "h-9 w-5"
                    : "h-8 w-5"
                  : state.dragging && state.active
                    ? "h-5 w-9"
                    : "h-5 w-8",
                state.disabled && "shadow-none"
              )
            }
          />
        ))}
      </SliderPrimitive.Control>
    </SliderPrimitive.Root>
  )
}

export { Slider }
```

4. Update the import paths to match your project.

**StyleSheet**

1. Add the pieces it builds on: the primitives `components/primitives/slider.tsx`, `components/primitives/use-render.tsx`.

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

```tsx title="stylesheet/components/ui/slider.tsx"
import { StyleSheet } from "react-native"
import * as SliderPrimitive from "@/components/primitives/slider"
import { shadow, useTheme } from "@/components/ui/theme"

const styles = StyleSheet.create({
  rootHorizontal: { width: "100%" },
  rootVertical: { height: "100%" },
  controlHorizontal: {
    position: "relative",
    height: 32,
    width: "100%",
    flexDirection: "row",
    alignItems: "center",
    justifyContent: "center",
  },
  controlVertical: {
    position: "relative",
    height: "100%",
    minHeight: 160,
    width: 32,
    flexDirection: "column",
    alignItems: "center",
    justifyContent: "center",
  },
  track: {
    position: "relative",
    flexGrow: 1,
    overflow: "hidden",
    borderRadius: 9999,
  },
  trackHorizontal: { height: 4, width: "100%" },
  trackVertical: { height: "100%", width: 4 },
  indicator: { borderRadius: 9999 },
  thumb: {
    borderRadius: 9999,
    borderWidth: 1,
    ...shadow.md,
  },
  thumbHorizontal: { height: 20, width: 32 },
  thumbHorizontalActive: { height: 20, width: 36 },
  thumbVertical: { height: 32, width: 20 },
  thumbVerticalActive: { height: 36, width: 20 },
  thumbDisabled: { boxShadow: [] },
})

function thumbIndexes(
  value: SliderPrimitive.SliderValue | undefined,
  defaultValue: SliderPrimitive.SliderValue | undefined
) {
  const source = Array.isArray(value) ? value : defaultValue
  const count = Array.isArray(source) ? source.length : 1
  return Array.from({ length: count }, (_, index) => index)
}

function Slider<
  Value extends SliderPrimitive.SliderValue = SliderPrimitive.SliderValue,
>({
  style,
  defaultValue,
  value,
  min = 0,
  max = 100,
  accessibilityLabel,
  ...props
}: SliderPrimitive.RootProps<Value>) {
  const { colors, isDark } = useTheme()
  return (
    <SliderPrimitive.Root
      style={(state) => [
        state.orientation === "vertical"
          ? styles.rootVertical
          : styles.rootHorizontal,
        typeof style === "function" ? style(state) : style,
      ]}
      defaultValue={defaultValue}
      value={value}
      min={min}
      max={max}
      thumbAlignment="edge"
      {...props}
    >
      <SliderPrimitive.Control
        style={(state) =>
          state.orientation === "vertical"
            ? styles.controlVertical
            : styles.controlHorizontal
        }
      >
        <SliderPrimitive.Track
          style={(state) => [
            styles.track,
            state.orientation === "vertical"
              ? styles.trackVertical
              : styles.trackHorizontal,
            {
              backgroundColor: state.disabled
                ? colors.controlDisabled
                : colors.labelQuaternary,
            },
          ]}
        >
          <SliderPrimitive.Indicator
            style={(state) => [
              styles.indicator,
              {
                backgroundColor: state.disabled
                  ? colors.accentDisabled
                  : colors.accent,
              },
            ]}
          />
        </SliderPrimitive.Track>
        {thumbIndexes(value, defaultValue).map((index) => (
          <SliderPrimitive.Thumb
            key={index}
            index={index}
            getAccessibilityLabel={
              accessibilityLabel ? () => accessibilityLabel : undefined
            }
            style={(state) => {
              const expanded = state.dragging && state.active
              const size =
                state.orientation === "vertical"
                  ? expanded
                    ? styles.thumbVerticalActive
                    : styles.thumbVertical
                  : expanded
                    ? styles.thumbHorizontalActive
                    : styles.thumbHorizontal
              return [
                styles.thumb,
                size,
                {
                  backgroundColor: isDark ? colors.label : colors.surface,
                  borderColor: `${colors.label}1a`,
                },
                state.disabled && styles.thumbDisabled,
              ]
            }}
          />
        ))}
      </SliderPrimitive.Control>
    </SliderPrimitive.Root>
  )
}

export { Slider }
```

3. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Copy the theme files every view uses:

```swift title="swift/Theme/Theme.swift"
import SwiftUI
import UIKit

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

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

  var uiColor: UIColor {
    UIColor(red: red, green: green, blue: blue, alpha: alpha)
  }
}

extension Color {
  static func adaptive(light: RGBA, dark: RGBA) -> Color {
    let lightColor = light.uiColor
    let darkColor = dark.uiColor
    return Color(uiColor: UIColor { traits in
      traits.userInterfaceStyle == .dark ? darkColor : lightColor
    })
  }
}

public struct TextStyle: Sendable, Hashable {
  public let size: CGFloat
  public let lineHeight: CGFloat
  public let letterSpacing: CGFloat

  public init(size: CGFloat, lineHeight: CGFloat, letterSpacing: CGFloat) {
    self.size = size
    self.lineHeight = lineHeight
    self.letterSpacing = letterSpacing
  }

  public func font(weight: Font.Weight = .regular) -> Font {
    .system(size: size, weight: weight)
  }

  public var lineSpacing: CGFloat {
    max(0, lineHeight - UIFont.systemFont(ofSize: size).lineHeight)
  }
}

extension Hashable where Self == Font {
  public static func textStyle(_ style: TextStyle, weight: Font.Weight = .regular) -> Font {
    style.font(weight: weight)
  }
}

private struct TextStyleModifier: ViewModifier {
  let style: TextStyle
  let weight: Font.Weight

  func body(content: Content) -> some View {
    content
      .font(style.font(weight: weight))
      .tracking(style.letterSpacing)
      .lineSpacing(style.lineSpacing)
      .padding(.vertical, style.lineSpacing / 2)
  }
}

extension View {
  public func textStyle(_ style: TextStyle, weight: Font.Weight = .regular) -> some View {
    modifier(TextStyleModifier(style: style, weight: weight))
  }
}

public struct Shadow: Sendable, Hashable {
  public let color: Color
  public let radius: CGFloat
  public let x: CGFloat
  public let y: CGFloat

  public init(color: Color, radius: CGFloat, x: CGFloat = 0, y: CGFloat = 0) {
    self.color = color
    self.radius = radius
    self.x = x
    self.y = y
  }
}

extension View {
  public func shadow(_ shadow: Shadow) -> some View {
    self.shadow(color: shadow.color, radius: shadow.radius, x: shadow.x, y: shadow.y)
  }
}
```

```swift title="swift/Theme/Tokens.swift"
import SwiftUI

extension Color {
  public static let surface = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x000000))
  public static let surfaceSecondary = Color.adaptive(light: .init(0xF5F5F7), dark: .init(0x161617))
  public static let surfaceTertiary = Color.adaptive(light: .init(0xE8E8ED), dark: .init(0x2C2C2E))
  public static let surfaceRaised = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x1D1D1F))
  public static let label = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let labelSecondary = Color.adaptive(light: .init(0x6E6E73), dark: .init(0xA1A1A6))
  public static let labelTertiary = Color.adaptive(light: .init(0x86868B), dark: .init(0x86868B))
  public static let labelQuaternary = Color.adaptive(light: .init(0xD2D2D7), dark: .init(0x424245))
  public static let control = Color.adaptive(light: .init(0x000000, 0.04), dark: .init(0xFFFFFF, 0.1))
  public static let controlPressed = Color.adaptive(light: .init(0x000000, 0.12), dark: .init(0xFFFFFF, 0.22))
  public static let controlSelected = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let controlDisabled = Color.adaptive(light: .init(0x000000, 0.03), dark: .init(0xFFFFFF, 0.05))
  public static let onControlSelected = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x000000))
  public static let itemPressed = Color.adaptive(light: .init(0x000000, 0.08), dark: .init(0xFFFFFF, 0.12))
  public static let itemSelected = Color.adaptive(light: .init(0x000000, 0.06), dark: .init(0xFFFFFF, 0.1))
  public static let separator = Color.adaptive(light: .init(0xD2D2D7), dark: .init(0x424245))
  public static let separatorStrong = Color.adaptive(light: .init(0x86868B), dark: .init(0x6E6E73))
  public static let accent = Color.adaptive(light: .init(0x0071E3), dark: .init(0x0071E3))
  public static let accentPressed = Color.adaptive(light: .init(0x005BB8), dark: .init(0x004D9E))
  public static let accentSelected = Color.adaptive(light: .init(0x0071E3), dark: .init(0x0071E3))
  public static let accentDisabled = Color.adaptive(light: .init(0x0071E3, 0.4), dark: .init(0x0071E3, 0.4))
  public static let onAccent = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0xFFFFFF))
  public static let accentSurface = Color.adaptive(light: .init(0x0071E3, 0.09), dark: .init(0x0071E3, 0.18))
  public static let accentSurfacePressed = Color.adaptive(light: .init(0x0071E3, 0.18), dark: .init(0x0071E3, 0.3))
  public static let link = Color.adaptive(light: .init(0x0066CC), dark: .init(0x2997FF))
  public static let focus = Color.adaptive(light: .init(0x0071E3), dark: .init(0x0071E3))
  public static let scrim = Color.adaptive(light: .init(0x000000, 0.48), dark: .init(0x000000, 0.48))
  public static let media = Color.adaptive(light: .init(0x000000), dark: .init(0x000000))
  public static let onMedia = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0xFFFFFF))
  public static let danger = Color.adaptive(light: .init(0xD0000A), dark: .init(0xFF5447))
  public static let dangerSurface = Color.adaptive(light: .init(0xD70015, 0.08), dark: .init(0xFF453A, 0.2))
  public static let dangerSurfacePressed = Color.adaptive(light: .init(0xD70015, 0.16), dark: .init(0xFF453A, 0.36))
  public static let success = Color.adaptive(light: .init(0x008009), dark: .init(0x30D158))
  public static let successSurface = Color.adaptive(light: .init(0xEFFAF1), dark: .init(0x30D158, 0.16))
  public static let warning = Color.adaptive(light: .init(0xB64400), dark: .init(0xFF9F0A))
  public static let warningSurface = Color.adaptive(light: .init(0xFFF5EA), dark: .init(0xFF9F0A, 0.16))
}

public enum Radius {
  public static let xs: CGFloat = 3
  public static let sm: CGFloat = 5
  public static let md: CGFloat = 8
  public static let lg: CGFloat = 10
  public static let xl: CGFloat = 12
  public static let xl2: CGFloat = 18
  public static let xl3: CGFloat = 28
  public static let full: CGFloat = 9999
}

extension TextStyle {
  public static let xs3 = TextStyle(size: 11, lineHeight: 13, letterSpacing: 0.066)
  public static let xs2 = TextStyle(size: 12, lineHeight: 16, letterSpacing: 0)
  public static let xs = TextStyle(size: 13, lineHeight: 18, letterSpacing: -0.078)
  public static let sm = TextStyle(size: 15, lineHeight: 20, letterSpacing: -0.24)
  public static let md = TextStyle(size: 16, lineHeight: 21, letterSpacing: -0.32)
  public static let base = TextStyle(size: 17, lineHeight: 22, letterSpacing: -0.442)
  public static let lg = TextStyle(size: 20, lineHeight: 25, letterSpacing: -0.46)
  public static let xl = TextStyle(size: 22, lineHeight: 28, letterSpacing: -0.264)
  public static let xl2 = TextStyle(size: 28, lineHeight: 34, letterSpacing: 0.392)
  public static let xl3 = TextStyle(size: 34, lineHeight: 41, letterSpacing: 0.408)

  public static let allCases: [TextStyle] = [.xs3, .xs2, .xs, .sm, .md, .base, .lg, .xl, .xl2, .xl3]
}

extension Shadow {
  public static let md = Shadow(color: .black.opacity(0.08), radius: 6, x: 2, y: 4)
  public static let lg = Shadow(color: .black.opacity(0.12), radius: 12, y: 4)
}
```

3. Copy the source and every file it uses:

```swift title="swift/Components/Slider.swift"
import SwiftUI

public struct ValueSlider<Value: BinaryFloatingPoint>: View where Value.Stride: BinaryFloatingPoint {
  @Binding var value: Value
  let bounds: ClosedRange<Value>
  let step: Value.Stride?
  let onEditingChanged: (Bool) -> Void

  public init(
    value: Binding<Value>,
    in bounds: ClosedRange<Value> = 0...1,
    step: Value.Stride? = nil,
    onEditingChanged: @escaping (Bool) -> Void = { _ in }
  ) {
    _value = value
    self.bounds = bounds
    self.step = step
    self.onEditingChanged = onEditingChanged
  }

  private var stepValue: Double? {
    guard let step else { return nil }
    return Double(step)
  }

  public var body: some View {
    SliderTrack(
      values: [Double(value)],
      bounds: Double(bounds.lowerBound)...Double(bounds.upperBound),
      step: stepValue,
      onEditingChanged: onEditingChanged
    ) { _, newValue in
      value = Value(newValue)
    }
  }
}

public struct RangeSlider<Value: BinaryFloatingPoint>: View where Value.Stride: BinaryFloatingPoint {
  @Binding var value: ClosedRange<Value>
  let bounds: ClosedRange<Value>
  let step: Value.Stride?
  let onEditingChanged: (Bool) -> Void

  public init(
    value: Binding<ClosedRange<Value>>,
    in bounds: ClosedRange<Value> = 0...1,
    step: Value.Stride? = nil,
    onEditingChanged: @escaping (Bool) -> Void = { _ in }
  ) {
    _value = value
    self.bounds = bounds
    self.step = step
    self.onEditingChanged = onEditingChanged
  }

  private var stepValue: Double? {
    guard let step else { return nil }
    return Double(step)
  }

  public var body: some View {
    SliderTrack(
      values: [Double(value.lowerBound), Double(value.upperBound)],
      bounds: Double(bounds.lowerBound)...Double(bounds.upperBound),
      step: stepValue,
      onEditingChanged: onEditingChanged
    ) { index, newValue in
      let updated = Value(newValue)
      if index == 0 {
        value = min(updated, value.upperBound)...value.upperBound
      } else {
        value = value.lowerBound...max(updated, value.lowerBound)
      }
    }
  }
}

private struct SliderTrack: View {
  let values: [Double]
  let bounds: ClosedRange<Double>
  let step: Double?
  let onEditingChanged: (Bool) -> Void
  let update: (Int, Double) -> Void

  @Environment(\.isEnabled) private var isEnabled
  @Environment(\.colorScheme) private var colorScheme
  @Environment(\.accessibilityReduceMotion) private var reduceMotion
  @State private var active: Int?
  @State private var ticks = 0

  private let thumbWidth: CGFloat = 32
  private let thumbHeight: CGFloat = 20

  init(
    values: [Double],
    bounds: ClosedRange<Double>,
    step: Double?,
    onEditingChanged: @escaping (Bool) -> Void,
    update: @escaping (Int, Double) -> Void
  ) {
    self.values = values
    self.bounds = bounds
    self.step = step
    self.onEditingChanged = onEditingChanged
    self.update = update
  }

  var body: some View {
    GeometryReader { proxy in
      let travel = max(0, proxy.size.width - thumbWidth)
      let centers = values.map { thumbWidth / 2 + travel * fraction($0) }
      let start = values.count > 1 ? centers[0] : 0
      let end = centers.last ?? 0
      ZStack(alignment: .leading) {
        Capsule()
          .fill(isEnabled ? Color.labelQuaternary : .controlDisabled)
          .frame(height: 4)
          .overlay(alignment: .leading) {
            Rectangle()
              .fill(isEnabled ? Color.accent : .accentDisabled)
              .frame(width: max(0, end - start))
              .offset(x: start)
          }
          .clipShape(Capsule())
        ForEach(values.indices, id: \.self) { index in
          thumb(dragging: active == index)
            .position(x: centers[index], y: proxy.size.height / 2)
            .accessibilityElement()
            .accessibilityLabel(index == 0 ? Text("Minimum") : Text("Maximum"))
            .accessibilityValue(Text(format(values[index])))
            .accessibilityAdjustableAction { adjust(index, $0) }
        }
      }
      .frame(maxHeight: .infinity)
      .contentShape(Rectangle())
      .gesture(
        DragGesture(minimumDistance: 0)
          .onChanged { gesture in
            let index = active ?? nearest(to: gesture.startLocation.x, centers: centers)
            if active == nil {
              withAnimation(reduceMotion ? nil : .timingCurve(0.32, 0.72, 0, 1, duration: 0.2)) {
                active = index
              }
              onEditingChanged(true)
            }
            let ratio = travel > 0 ? (gesture.location.x - thumbWidth / 2) / travel : 0
            change(index, clamp(bounds.lowerBound + Double(ratio) * (bounds.upperBound - bounds.lowerBound)))
          }
          .onEnded { _ in
            withAnimation(reduceMotion ? nil : .timingCurve(0.32, 0.72, 0, 1, duration: 0.2)) {
              active = nil
            }
            onEditingChanged(false)
          }
      )
    }
    .frame(height: 32)
    .hapticFeedback(.selection, trigger: ticks)
    .accessibilityElement(children: values.count > 1 ? .contain : .ignore)
    .accessibilityValue(values.count > 1 ? Text(verbatim: "") : Text(format(values.first ?? 0)))
    .accessibilityAdjustableAction { direction in
      if values.count == 1 { adjust(0, direction) }
    }
  }

  private func adjust(_ index: Int, _ direction: AccessibilityAdjustmentDirection) {
    let delta = step ?? (bounds.upperBound - bounds.lowerBound) / 10
    let next = direction == .increment ? values[index] + delta : values[index] - delta
    change(index, clamp(next))
  }

  private func change(_ index: Int, _ value: Double) {
    var next = value
    if values.count > 1 {
      next = index == 0 ? min(next, values[1]) : max(next, values[0])
    }
    let before = HapticMoments.step(values[index], bounds: bounds, step: step)
    let after = HapticMoments.step(next, bounds: bounds, step: step)
    update(index, next)
    if before != after { ticks += 1 }
  }

  private func thumb(dragging: Bool) -> some View {
    Capsule()
      .fill(colorScheme == .dark ? Color.label : .surface)
      .overlay {
        Capsule().strokeBorder(Color.label.opacity(0.1), lineWidth: 1)
      }
      .frame(width: dragging ? thumbWidth + 4 : thumbWidth, height: thumbHeight)
      .shadow(isEnabled ? .md : Shadow(color: .clear, radius: 0))
  }

  private func fraction(_ value: Double) -> CGFloat {
    let span = bounds.upperBound - bounds.lowerBound
    guard span > 0 else { return 0 }
    return CGFloat(min(max((value - bounds.lowerBound) / span, 0), 1))
  }

  private func clamp(_ value: Double) -> Double {
    var result = min(max(value, bounds.lowerBound), bounds.upperBound)
    if let step, step > 0 {
      result = bounds.lowerBound + ((result - bounds.lowerBound) / step).rounded() * step
      result = min(result, bounds.upperBound)
    }
    return result
  }

  private func nearest(to x: CGFloat, centers: [CGFloat]) -> Int {
    guard centers.count > 1 else { return 0 }
    if centers[0] == centers[1] {
      return x < centers[0] ? 0 : 1
    }
    return abs(x - centers[0]) <= abs(x - centers[1]) ? 0 : 1
  }

  private func format(_ value: Double) -> String {
    value.formatted(.number.precision(.fractionLength(0...2)))
  }
}
```

```swift title="swift/Components/Haptics.swift"
import SwiftUI

extension EnvironmentValues {
  @Entry public var hapticsEnabled = true
}

extension View {
  public func haptics(_ enabled: Bool) -> some View {
    environment(\.hapticsEnabled, enabled)
  }

  func hapticFeedback<Trigger: Equatable>(_ feedback: SensoryFeedback, trigger: Trigger) -> some View {
    modifier(HapticFeedbackModifier(feedback: feedback, trigger: trigger) { _, _ in true })
  }

  func hapticFeedback<Trigger: Equatable>(
    _ feedback: SensoryFeedback,
    trigger: Trigger,
    condition: @escaping (Trigger, Trigger) -> Bool
  ) -> some View {
    modifier(HapticFeedbackModifier(feedback: feedback, trigger: trigger, condition: condition))
  }
}

private struct HapticFeedbackModifier<Trigger: Equatable>: ViewModifier {
  let feedback: SensoryFeedback
  let trigger: Trigger
  let condition: (Trigger, Trigger) -> Bool

  @Environment(\.hapticsEnabled) private var enabled

  func body(content: Content) -> some View {
    let enabled = enabled
    let condition = condition
    content.sensoryFeedback(feedback, trigger: trigger) { old, new in
      enabled && condition(old, new)
    }
  }
}

enum HapticMoments {
  static func step(_ value: Double, bounds: ClosedRange<Double>, step: Double?) -> Int? {
    guard let step, step > 0 else { return nil }
    return Int(((value - bounds.lowerBound) / step).rounded())
  }

  static func lifted<Value: Equatable>(_ old: Value?, _ new: Value?) -> Bool {
    new != nil && old != new
  }

  static func crossed<Value: Equatable>(_ old: Value?, _ new: Value?) -> Bool {
    old == nil && new != nil
  }

  static func moved(_ old: Int?, _ new: Int?) -> Bool {
    guard let old, let new else { return false }
    return old != new
  }
}

struct HapticButton<Style: ButtonStyle>: View {
  let feedback: SensoryFeedback
  let action: () -> Bool
  let style: Style

  @State private var count = 0

  init(_ feedback: SensoryFeedback = .selection, style: Style, action: @escaping () -> Bool) {
    self.feedback = feedback
    self.style = style
    self.action = action
  }

  var body: some View {
    Button {
      if action() { count += 1 }
    } label: {
      EmptyView()
    }
    .buttonStyle(style)
    .hapticFeedback(feedback, trigger: count)
  }
}
```

4. 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")
```

2. Copy the theme files every view uses:

```kt title="kotlin/theme/Theme.kt"
package com.esuiss.ui.theme

import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.shadow.Shadow as DropShadow
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp

internal fun textStyle(size: Double, lineHeight: Double, letterSpacing: Double) = TextStyle(
    fontSize = size.sp,
    lineHeight = lineHeight.sp,
    letterSpacing = letterSpacing.em,
    lineHeightStyle = LineHeightStyle(
        alignment = LineHeightStyle.Alignment.Center,
        trim = LineHeightStyle.Trim.None,
    ),
)

@Immutable
data class Shadow(
    val offsetX: Dp,
    val offsetY: Dp,
    val blur: Dp,
    val color: Color,
)

fun Modifier.shadow(shadow: Shadow, shape: Shape): Modifier = dropShadow(
    shape,
    DropShadow(
        radius = shadow.blur,
        color = shadow.color,
        offset = DpOffset(shadow.offsetX, shadow.offsetY),
    ),
)

val LocalColors = staticCompositionLocalOf { LightColors }

object EsuissTheme {
    val colors: Colors
        @Composable get() = LocalColors.current

    val typography: Typography
        get() = Typography

    val radius: Radius
        get() = Radius

    val shadows: Shadows
        get() = Shadows
}

@Composable
fun EsuissTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit,
) {
    CompositionLocalProvider(
        LocalColors provides if (darkTheme) DarkColors else LightColors,
        content = content,
    )
}
```

```kt title="kotlin/theme/Tokens.kt"
package com.esuiss.ui.theme

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

@Immutable
data class Colors(
    val surface: Color,
    val surfaceSecondary: Color,
    val surfaceTertiary: Color,
    val surfaceRaised: Color,
    val label: Color,
    val labelSecondary: Color,
    val labelTertiary: Color,
    val labelQuaternary: Color,
    val control: Color,
    val controlPressed: Color,
    val controlSelected: Color,
    val controlDisabled: Color,
    val onControlSelected: Color,
    val itemPressed: Color,
    val itemSelected: Color,
    val separator: Color,
    val separatorStrong: Color,
    val accent: Color,
    val accentPressed: Color,
    val accentSelected: Color,
    val accentDisabled: Color,
    val onAccent: Color,
    val accentSurface: Color,
    val accentSurfacePressed: Color,
    val link: Color,
    val focus: Color,
    val scrim: Color,
    val media: Color,
    val onMedia: Color,
    val danger: Color,
    val dangerSurface: Color,
    val dangerSurfacePressed: Color,
    val success: Color,
    val successSurface: Color,
    val warning: Color,
    val warningSurface: Color,
    val blue: Color,
    val green: Color,
    val orange: Color,
    val red: Color,
    val yellow: Color,
    val purple: Color,
    val pink: Color,
    val indigo: Color,
    val teal: Color,
    val mint: Color,
    val cyan: Color,
    val brown: Color,
    val gray: Color,
    val isDark: Boolean,
)

val LightColors = Colors(
    surface = Color(0xFFFFFFFF),
    surfaceSecondary = Color(0xFFF5F5F7),
    surfaceTertiary = Color(0xFFE8E8ED),
    surfaceRaised = Color(0xFFFFFFFF),
    label = Color(0xFF1D1D1F),
    labelSecondary = Color(0xFF6E6E73),
    labelTertiary = Color(0xFF86868B),
    labelQuaternary = Color(0xFFD2D2D7),
    control = Color(0xFF000000).copy(alpha = 0.04f),
    controlPressed = Color(0xFF000000).copy(alpha = 0.12f),
    controlSelected = Color(0xFF1D1D1F),
    controlDisabled = Color(0xFF000000).copy(alpha = 0.03f),
    onControlSelected = Color(0xFFFFFFFF),
    itemPressed = Color(0xFF000000).copy(alpha = 0.08f),
    itemSelected = Color(0xFF000000).copy(alpha = 0.06f),
    separator = Color(0xFFD2D2D7),
    separatorStrong = Color(0xFF86868B),
    accent = Color(0xFF0071E3),
    accentPressed = Color(0xFF005BB8),
    accentSelected = Color(0xFF0071E3),
    accentDisabled = Color(0xFF0071E3).copy(alpha = 0.4f),
    onAccent = Color(0xFFFFFFFF),
    accentSurface = Color(0xFF0071E3).copy(alpha = 0.09f),
    accentSurfacePressed = Color(0xFF0071E3).copy(alpha = 0.18f),
    link = Color(0xFF0066CC),
    focus = Color(0xFF0071E3),
    scrim = Color(0xFF000000).copy(alpha = 0.48f),
    media = Color(0xFF000000),
    onMedia = Color(0xFFFFFFFF),
    danger = Color(0xFFD0000A),
    dangerSurface = Color(0xFFD70015).copy(alpha = 0.08f),
    dangerSurfacePressed = Color(0xFFD70015).copy(alpha = 0.16f),
    success = Color(0xFF008009),
    successSurface = Color(0xFFEFFAF1),
    warning = Color(0xFFB64400),
    warningSurface = Color(0xFFFFF5EA),
    blue = Color(0xFF007AFF),
    green = Color(0xFF34C759),
    orange = Color(0xFFFF9500),
    red = Color(0xFFFF3B30),
    yellow = Color(0xFFFFCC00),
    purple = Color(0xFFAF52DE),
    pink = Color(0xFFFF2D55),
    indigo = Color(0xFF5856D6),
    teal = Color(0xFF30B0C7),
    mint = Color(0xFF00C7BE),
    cyan = Color(0xFF32ADE6),
    brown = Color(0xFFA2845E),
    gray = Color(0xFF8E8E93),
    isDark = false,
)

val DarkColors = Colors(
    surface = Color(0xFF000000),
    surfaceSecondary = Color(0xFF161617),
    surfaceTertiary = Color(0xFF2C2C2E),
    surfaceRaised = Color(0xFF1D1D1F),
    label = Color(0xFFF5F5F7),
    labelSecondary = Color(0xFFA1A1A6),
    labelTertiary = Color(0xFF86868B),
    labelQuaternary = Color(0xFF424245),
    control = Color(0xFFFFFFFF).copy(alpha = 0.1f),
    controlPressed = Color(0xFFFFFFFF).copy(alpha = 0.22f),
    controlSelected = Color(0xFFF5F5F7),
    controlDisabled = Color(0xFFFFFFFF).copy(alpha = 0.05f),
    onControlSelected = Color(0xFF000000),
    itemPressed = Color(0xFFFFFFFF).copy(alpha = 0.12f),
    itemSelected = Color(0xFFFFFFFF).copy(alpha = 0.1f),
    separator = Color(0xFF424245),
    separatorStrong = Color(0xFF6E6E73),
    accent = Color(0xFF0071E3),
    accentPressed = Color(0xFF004D9E),
    accentSelected = Color(0xFF0071E3),
    accentDisabled = Color(0xFF0071E3).copy(alpha = 0.4f),
    onAccent = Color(0xFFFFFFFF),
    accentSurface = Color(0xFF0071E3).copy(alpha = 0.18f),
    accentSurfacePressed = Color(0xFF0071E3).copy(alpha = 0.3f),
    link = Color(0xFF2997FF),
    focus = Color(0xFF0071E3),
    scrim = Color(0xFF000000).copy(alpha = 0.48f),
    media = Color(0xFF000000),
    onMedia = Color(0xFFFFFFFF),
    danger = Color(0xFFFF5447),
    dangerSurface = Color(0xFFFF453A).copy(alpha = 0.2f),
    dangerSurfacePressed = Color(0xFFFF453A).copy(alpha = 0.36f),
    success = Color(0xFF30D158),
    successSurface = Color(0xFF30D158).copy(alpha = 0.16f),
    warning = Color(0xFFFF9F0A),
    warningSurface = Color(0xFFFF9F0A).copy(alpha = 0.16f),
    blue = Color(0xFF0A84FF),
    green = Color(0xFF30D158),
    orange = Color(0xFFFF9F0A),
    red = Color(0xFFFF453A),
    yellow = Color(0xFFFFD60A),
    purple = Color(0xFFBF5AF2),
    pink = Color(0xFFFF375F),
    indigo = Color(0xFF5E5CE6),
    teal = Color(0xFF40C8E0),
    mint = Color(0xFF66D4CF),
    cyan = Color(0xFF64D2FF),
    brown = Color(0xFFAC8E68),
    gray = Color(0xFF8E8E93),
    isDark = true,
)

object Radius {
    val xs = 3.dp
    val sm = 5.dp
    val md = 8.dp
    val lg = 10.dp
    val xl = 12.dp
    val xl2 = 18.dp
    val xl3 = 28.dp
    val full = 9999.dp
}

object Typography {
    val xs3 = textStyle(11.0, 13.0, 0.006)
    val xs2 = textStyle(12.0, 16.0, 0.0)
    val xs = textStyle(13.0, 18.0, -0.006)
    val sm = textStyle(15.0, 20.0, -0.016)
    val md = textStyle(16.0, 21.0, -0.02)
    val base = textStyle(17.0, 22.0, -0.026)
    val lg = textStyle(20.0, 25.0, -0.023)
    val xl = textStyle(22.0, 28.0, -0.012)
    val xl2 = textStyle(28.0, 34.0, 0.014)
    val xl3 = textStyle(34.0, 41.0, 0.012)

    val all = listOf(xs3, xs2, xs, sm, md, base, lg, xl, xl2, xl3)
}

object Shadows {
    val md = Shadow(2.dp, 4.dp, 12.dp, Color.Black.copy(alpha = 0.08f))
    val lg = Shadow(0.dp, 4.dp, 24.dp, Color.Black.copy(alpha = 0.12f))
}
```

3. Copy the source and every file it uses:

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

import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.awaitHorizontalTouchSlopOrCancellation
import androidx.compose.foundation.gestures.horizontalDrag
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.semantics.ProgressBarRangeInfo
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.progressBarRangeInfo
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.setProgress
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.constrainHeight
import androidx.compose.ui.unit.dp
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Shadows
import com.esuiss.ui.theme.shadow
import kotlin.math.abs
import kotlin.math.roundToInt

@Composable
fun Slider(
    value: Float,
    onValueChange: (Float) -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = LocalFieldState.current.enabled,
    valueRange: ClosedFloatingPointRange<Float> = 0f..100f,
    step: Float = 1f,
    onValueChangeFinished: (() -> Unit)? = null,
    contentDescription: String? = null,
) {
    SliderLayout(
        values = listOf(value),
        onValuesChange = { onValueChange(it[0]) },
        modifier = modifier,
        enabled = enabled,
        valueRange = valueRange,
        step = step,
        onValueChangeFinished = onValueChangeFinished,
        contentDescription = contentDescription,
    )
}

@Composable
fun RangeSlider(
    value: ClosedFloatingPointRange<Float>,
    onValueChange: (ClosedFloatingPointRange<Float>) -> Unit,
    modifier: Modifier = Modifier,
    enabled: Boolean = LocalFieldState.current.enabled,
    valueRange: ClosedFloatingPointRange<Float> = 0f..100f,
    step: Float = 1f,
    onValueChangeFinished: (() -> Unit)? = null,
    contentDescription: String? = null,
) {
    SliderLayout(
        values = listOf(value.start, value.endInclusive),
        onValuesChange = { onValueChange(it[0]..it[1]) },
        modifier = modifier,
        enabled = enabled,
        valueRange = valueRange,
        step = step,
        onValueChangeFinished = onValueChangeFinished,
        contentDescription = contentDescription,
    )
}

@Composable
private fun SliderLayout(
    values: List<Float>,
    onValuesChange: (List<Float>) -> Unit,
    modifier: Modifier,
    enabled: Boolean,
    valueRange: ClosedFloatingPointRange<Float>,
    step: Float,
    onValueChangeFinished: (() -> Unit)?,
    contentDescription: String?,
) {
    val colors = EsuissTheme.colors
    val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl
    val start = valueRange.start
    val span = valueRange.endInclusive - valueRange.start
    val currentValues by rememberUpdatedState(values)
    val currentOnChange by rememberUpdatedState(onValuesChange)
    val currentOnFinished by rememberUpdatedState(onValueChangeFinished)
    val currentHaptics by rememberUpdatedState(rememberHaptics())
    var activeThumb by remember { mutableIntStateOf(-1) }

    fun snap(raw: Float): Float {
        val clamped = raw.coerceIn(valueRange)
        if (step <= 0f) return clamped
        return (start + ((clamped - start) / step).roundToInt() * step).coerceIn(valueRange)
    }

    fun fractionOf(value: Float): Float = if (span <= 0f) 0f else ((value - start) / span).coerceIn(0f, 1f)

    fun update(index: Int, raw: Float): Boolean {
        val next = currentValues.toMutableList()
        var value = snap(raw)
        if (next.size == 2) {
            value = if (index == 0) value.coerceAtMost(next[1]) else value.coerceAtLeast(next[0])
        }
        next[index] = value
        if (next != currentValues) {
            if (step > 0f) currentHaptics.perform(HapticFeedbackType.SegmentFrequentTick)
            currentOnChange(next)
        }
        return true
    }

    val thumbWidth = 32.dp
    val gesture = if (enabled) {
        Modifier.pointerInput(valueRange, step, rtl) {
            val thumb = thumbWidth.toPx()
            fun valueAt(x: Float): Float {
                val usable = (size.width - thumb).coerceAtLeast(1f)
                var fraction = ((x - thumb / 2f) / usable).coerceIn(0f, 1f)
                if (rtl) fraction = 1f - fraction
                return start + fraction * span
            }
            fun pick(target: Float): Int {
                val list = currentValues
                if (list.size == 1) return 0
                val first = abs(list[0] - target)
                val second = abs(list[1] - target)
                return when {
                    first < second -> 0
                    second < first -> 1
                    target > list[1] -> 1
                    else -> 0
                }
            }
            awaitEachGesture {
                val down = awaitFirstDown(requireUnconsumed = false)
                val index = pick(valueAt(down.position.x))
                activeThumb = index
                try {
                    val slop = awaitHorizontalTouchSlopOrCancellation(down.id) { change, _ -> change.consume() }
                    if (slop != null) {
                        update(index, valueAt(slop.position.x))
                        horizontalDrag(slop.id) { change ->
                            update(index, valueAt(change.position.x))
                            change.consume()
                        }
                        currentOnFinished?.invoke()
                    } else {
                        val up = currentEvent.changes.firstOrNull { it.id == down.id }
                        if (up != null && up.changedToUp() && !up.isConsumed) {
                            update(index, valueAt(up.position.x))
                            up.consume()
                            currentOnFinished?.invoke()
                        }
                    }
                } finally {
                    activeThumb = -1
                }
            }
        }
    } else {
        Modifier
    }

    val fractions = values.map { fractionOf(it) }
    val stepCount = if (step > 0f && span > 0f) ((span / step).roundToInt() - 1).coerceAtLeast(0) else 0

    Layout(
        content = {
            Box(
                Modifier
                    .fillMaxWidth()
                    .height(4.dp)
                    .clip(CircleShape)
                    .background(if (enabled) colors.labelQuaternary else colors.controlDisabled)
                    .drawBehind {
                        val thumb = thumbWidth.toPx()
                        val usable = size.width - thumb
                        val centers = fractions.map { thumb / 2f + it * usable }
                        val from = if (centers.size == 1) 0f else centers[0]
                        val to = centers.last()
                        val left = if (rtl) size.width - to else from
                        val right = if (rtl) size.width - from else to
                        drawRect(
                            if (enabled) colors.accent else colors.accentDisabled,
                            Offset(left, 0f),
                            Size((right - left).coerceAtLeast(0f), size.height),
                        )
                    },
            )
            values.forEachIndexed { index, value ->
                SliderThumb(
                    value = value,
                    valueRange = valueRange,
                    stepCount = stepCount,
                    enabled = enabled,
                    dragging = activeThumb == index,
                    contentDescription = contentDescription,
                    onSetProgress = {
                        update(index, it)
                        currentOnFinished?.invoke()
                    },
                )
            }
        },
        modifier = modifier
            .then(gesture)
            .formTouchTarget()
            .fillMaxWidth()
            .height(32.dp),
    ) { measurables, constraints ->
        val width = if (constraints.hasBoundedWidth) constraints.maxWidth else 200.dp.roundToPx()
        val height = constraints.constrainHeight(32.dp.roundToPx())
        val track = measurables[0].measure(Constraints.fixed(width, 4.dp.roundToPx()))
        val thumbs = measurables.drop(1).map { it.measure(Constraints()) }
        val thumb = thumbWidth.toPx()
        layout(width, height) {
            track.place(0, (height - track.height) / 2)
            thumbs.forEachIndexed { index, placeable ->
                val center = thumb / 2f + fractions[index] * (width - thumb)
                val x = if (rtl) width - center else center
                placeable.place((x - placeable.width / 2f).roundToInt(), (height - placeable.height) / 2)
            }
        }
    }
}

@Composable
private fun SliderThumb(
    value: Float,
    valueRange: ClosedFloatingPointRange<Float>,
    stepCount: Int,
    enabled: Boolean,
    dragging: Boolean,
    contentDescription: String?,
    onSetProgress: (Float) -> Unit,
) {
    val colors = EsuissTheme.colors
    val width by animateDpAsState(
        if (dragging && enabled) 36.dp else 32.dp,
        tween<Dp>(200, easing = FormEasing),
        label = "thumb",
    )
    Box(
        Modifier
            .semantics {
                if (contentDescription != null) this.contentDescription = contentDescription
                progressBarRangeInfo = ProgressBarRangeInfo(value, valueRange, stepCount)
                if (enabled) {
                    setProgress { target ->
                        onSetProgress(target)
                        true
                    }
                } else {
                    disabled()
                }
            }
            .size(width, 20.dp)
            .then(if (enabled) Modifier.shadow(Shadows.md, CircleShape) else Modifier)
            .background(if (colors.isDark) colors.label else colors.surface, CircleShape)
            .border(1.dp, colors.label.copy(alpha = 0.1f), CircleShape),
    )
}
```

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

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

val LocalContentStyle = compositionLocalOf { TextStyle.Default }

val LocalContentColor = compositionLocalOf { Color.Unspecified }

val LocalIconSize = compositionLocalOf { 20.dp }
```

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

import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.LayoutScopeMarker
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.Immutable
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.layout.layout
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography

enum class FieldOrientation { Vertical, Horizontal, Responsive }

enum class FieldGroupVariant { Default, Inset }

enum class FieldLegendVariant { Legend, Label }

@Immutable
internal class FieldState(val enabled: Boolean, val invalid: Boolean)

internal val LocalFieldState = staticCompositionLocalOf { FieldState(enabled = true, invalid = false) }

private val LocalFieldSetInset = staticCompositionLocalOf { false }

private val LocalFieldGroupVariant = staticCompositionLocalOf<FieldGroupVariant?> { null }

private val LocalFieldInset = staticCompositionLocalOf { false }

@LayoutScopeMarker
@Immutable
interface FieldScope {
    fun Modifier.weight(weight: Float): Modifier
}

private class RowFieldScope(private val scope: RowScope) : FieldScope {
    override fun Modifier.weight(weight: Float): Modifier = with(scope) { this@weight.weight(weight, true) }
}

private object ColumnFieldScope : FieldScope {
    override fun Modifier.weight(weight: Float): Modifier = this
}

@Composable
fun FieldSet(
    modifier: Modifier = Modifier,
    variant: FieldGroupVariant = FieldGroupVariant.Default,
    enabled: Boolean = true,
    content: @Composable ColumnScope.() -> Unit,
) {
    val inset = variant == FieldGroupVariant.Inset
    CompositionLocalProvider(
        LocalFieldSetInset provides inset,
        LocalFieldState provides FieldState(enabled, LocalFieldState.current.invalid),
    ) {
        Column(
            modifier = modifier.fillMaxWidth(),
            verticalArrangement = Arrangement.spacedBy(if (inset) 8.dp else 24.dp),
            content = content,
        )
    }
}

@Composable
fun FieldLegend(
    text: String,
    modifier: Modifier = Modifier,
    variant: FieldLegendVariant = FieldLegendVariant.Legend,
) {
    val colors = EsuissTheme.colors
    val inset = LocalFieldSetInset.current
    val enabled = LocalFieldState.current.enabled
    val style = if (inset) {
        Typography.xs.copy(color = if (enabled) colors.labelSecondary else colors.labelQuaternary)
    } else {
        Typography.base.copy(color = if (enabled) colors.label else colors.labelQuaternary, fontWeight = FontWeight.SemiBold)
    }
    BasicText(
        text = if (inset) text.uppercase() else text,
        modifier = modifier
            .then(if (variant == FieldLegendVariant.Legend) Modifier.semantics { heading() } else Modifier)
            .padding(start = if (inset) 16.dp else 0.dp, bottom = if (inset) 0.dp else 12.dp),
        style = style,
    )
}

@Composable
fun FieldGroup(
    modifier: Modifier = Modifier,
    variant: FieldGroupVariant = FieldGroupVariant.Default,
    content: @Composable () -> Unit,
) {
    val colors = EsuissTheme.colors
    CompositionLocalProvider(
        LocalFieldSetInset provides false,
        LocalFieldGroupVariant provides variant,
    ) {
        if (variant == FieldGroupVariant.Inset) {
            FormDividedStack(
                orientation = Orientation.Vertical,
                dividerColor = colors.separator,
                modifier = modifier
                    .fillMaxWidth()
                    .clip(RoundedCornerShape(Radius.xl2))
                    .background(colors.surfaceSecondary),
                dividerInsetStart = 16.dp,
                content = content,
            )
        } else {
            Column(
                modifier = modifier.fillMaxWidth(),
                verticalArrangement = Arrangement.spacedBy(28.dp),
            ) {
                content()
            }
        }
    }
}

@Composable
fun Field(
    modifier: Modifier = Modifier,
    orientation: FieldOrientation = FieldOrientation.Vertical,
    enabled: Boolean = LocalFieldState.current.enabled,
    invalid: Boolean = false,
    content: @Composable FieldScope.() -> Unit,
) {
    val inset = LocalFieldGroupVariant.current == FieldGroupVariant.Inset
    val horizontal = when (orientation) {
        FieldOrientation.Vertical -> false
        FieldOrientation.Horizontal -> true
        FieldOrientation.Responsive -> LocalConfiguration.current.screenWidthDp >= 768
    }
    val sizing = modifier
        .fillMaxWidth()
        .then(if (inset) Modifier.heightIn(min = 44.dp).padding(horizontal = 16.dp, vertical = 6.dp) else Modifier)
    CompositionLocalProvider(
        LocalFieldGroupVariant provides null,
        LocalFieldInset provides inset,
        LocalFieldState provides FieldState(enabled, invalid),
    ) {
        if (horizontal) {
            Row(
                modifier = sizing,
                horizontalArrangement = Arrangement.spacedBy(12.dp),
                verticalAlignment = Alignment.CenterVertically,
            ) {
                val scope = remember(this) { RowFieldScope(this) }
                scope.content()
            }
        } else {
            Column(
                modifier = sizing,
                verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically),
            ) {
                ColumnFieldScope.content()
            }
        }
    }
}

@Composable
fun FieldScope.FieldContent(
    modifier: Modifier = Modifier,
    content: @Composable ColumnScope.() -> Unit,
) {
    Column(
        modifier = modifier.weight(1f),
        verticalArrangement = Arrangement.spacedBy(4.dp),
        content = content,
    )
}

@Composable
fun FieldLabel(text: String, modifier: Modifier = Modifier) {
    val colors = EsuissTheme.colors
    val enabled = LocalFieldState.current.enabled
    BasicText(
        text = text,
        modifier = modifier,
        style = Typography.base.copy(
            color = if (enabled) colors.label else colors.labelQuaternary,
            fontWeight = if (LocalFieldInset.current) FontWeight.Normal else FontWeight.SemiBold,
        ),
    )
}

@Composable
fun FieldTitle(text: String, modifier: Modifier = Modifier) {
    val colors = EsuissTheme.colors
    val enabled = LocalFieldState.current.enabled
    BasicText(
        text = text,
        modifier = modifier,
        style = Typography.base.copy(
            color = if (enabled) colors.label else colors.labelQuaternary,
            fontWeight = FontWeight.SemiBold,
        ),
    )
}

@Composable
fun FieldDescription(text: String, modifier: Modifier = Modifier) {
    val colors = EsuissTheme.colors
    val inset = LocalFieldSetInset.current
    BasicText(
        text = text,
        modifier = modifier.padding(start = if (inset) 16.dp else 0.dp),
        style = (if (inset) Typography.xs else Typography.sm).copy(color = colors.labelSecondary),
    )
}

@Composable
fun FieldSeparator(
    modifier: Modifier = Modifier,
    text: String? = null,
) {
    val colors = EsuissTheme.colors
    Box(
        modifier = modifier
            .fillMaxWidth()
            .layout { measurable, constraints ->
                val placeable = measurable.measure(constraints)
                val overlap = 8.dp.roundToPx()
                val height = (placeable.height - overlap * 2).coerceAtLeast(0)
                layout(placeable.width, height) { placeable.place(0, -overlap) }
            }
            .heightIn(min = 20.dp),
        contentAlignment = Alignment.Center,
    ) {
        Separator()
        if (text != null) {
            BasicText(
                text = text,
                modifier = Modifier.background(colors.surface).padding(horizontal = 8.dp),
                style = Typography.sm.copy(color = colors.labelSecondary),
            )
        }
    }
}

@Composable
fun FieldError(text: String?, modifier: Modifier = Modifier) {
    if (text.isNullOrEmpty()) return
    BasicText(
        text = text,
        modifier = modifier.semantics { liveRegion = LiveRegionMode.Polite },
        style = Typography.sm.copy(color = EsuissTheme.colors.danger),
    )
}

@Composable
fun FieldError(errors: List<String?>, modifier: Modifier = Modifier) {
    val messages = errors.filterNotNull().filter { it.isNotEmpty() }.distinct()
    val text = when (messages.size) {
        0 -> null
        1 -> messages[0]
        else -> messages.joinToString("\n") { "• $it" }
    }
    FieldError(text, modifier)
}
```

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

import android.provider.Settings
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.constrainHeight
import androidx.compose.ui.unit.constrainWidth
import androidx.compose.ui.unit.dp
import com.esuiss.ui.theme.Colors
import com.esuiss.ui.theme.Shadow

internal val FormShadowXs = Shadow(0.dp, 1.dp, 2.dp, Color.Black.copy(alpha = 0.05f))

internal val FormShadowSm = Shadow(0.dp, 1.dp, 3.dp, Color.Black.copy(alpha = 0.1f))

internal val FormEasing = CubicBezierEasing(0.32f, 0.72f, 0f, 1f)

internal fun Modifier.formTouchTarget(): Modifier = layout { measurable, constraints ->
    val minimum = 48.dp.roundToPx()
    val placeable = measurable.measure(constraints)
    val width = constraints.constrainWidth(maxOf(placeable.width, minimum))
    val height = constraints.constrainHeight(maxOf(placeable.height, minimum))
    layout(width, height) {
        placeable.place((width - placeable.width) / 2, (height - placeable.height) / 2)
    }
}

@Composable
internal fun formReducedMotion(): Boolean {
    val context = LocalContext.current
    return remember(context) {
        Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f
    }
}

internal fun formSelectionColors(colors: Colors) = TextSelectionColors(
    handleColor = colors.accent,
    backgroundColor = colors.accent.copy(alpha = 0.3f),
)

internal fun Modifier.formInputSurface(
    colors: Colors,
    shape: Shape,
    focused: Boolean,
    invalid: Boolean,
    enabled: Boolean,
): Modifier {
    val background = when {
        !enabled -> colors.controlDisabled
        invalid -> colors.danger.copy(alpha = if (colors.isDark) 0.1f else 0.05f)
        else -> colors.control
    }
    val border = when {
        invalid -> colors.danger
        focused -> colors.focus
        else -> Color.Transparent
    }
    return clip(shape).background(background, shape).border(1.dp, border, shape)
}

@Composable
internal fun FormDividedStack(
    orientation: Orientation,
    dividerColor: Color,
    modifier: Modifier = Modifier,
    dividerInsetStart: Dp = 0.dp,
    content: @Composable () -> Unit,
) {
    val boundaries = remember { mutableStateOf(IntArray(0)) }
    val vertical = orientation == Orientation.Vertical
    Layout(
        content = content,
        modifier = modifier.drawWithContent {
            drawContent()
            val stroke = 1.dp.toPx()
            val inset = dividerInsetStart.toPx()
            boundaries.value.forEach { position ->
                if (vertical) {
                    val start = if (layoutDirection == LayoutDirection.Rtl) 0f else inset
                    drawRect(dividerColor, Offset(start, position.toFloat()), Size(size.width - inset, stroke))
                } else {
                    val x = if (layoutDirection == LayoutDirection.Rtl) size.width - position - stroke else position.toFloat()
                    drawRect(dividerColor, Offset(x, 0f), Size(stroke, size.height))
                }
            }
        },
    ) { measurables, constraints ->
        if (vertical) {
            val width = if (constraints.hasFixedWidth) {
                constraints.maxWidth
            } else {
                constraints.constrainWidth(measurables.maxOfOrNull { it.maxIntrinsicWidth(Constraints.Infinity) } ?: 0)
            }
            val placeables = measurables.map {
                it.measure(Constraints(minWidth = width, maxWidth = width))
            }
            val offsets = IntArray(placeables.size)
            var y = 0
            placeables.forEachIndexed { index, placeable ->
                offsets[index] = y
                y += placeable.height
            }
            val next = offsets.drop(1).toIntArray()
            if (!next.contentEquals(boundaries.value)) boundaries.value = next
            layout(width, constraints.constrainHeight(y)) {
                placeables.forEachIndexed { index, placeable -> placeable.placeRelative(0, offsets[index]) }
            }
        } else {
            val height = if (constraints.hasFixedHeight) {
                constraints.maxHeight
            } else {
                constraints.constrainHeight(measurables.maxOfOrNull { it.maxIntrinsicHeight(Constraints.Infinity) } ?: 0)
            }
            val placeables = measurables.map {
                it.measure(Constraints(minHeight = height, maxHeight = height))
            }
            val offsets = IntArray(placeables.size)
            var x = 0
            placeables.forEachIndexed { index, placeable ->
                offsets[index] = x
                x += placeable.width
            }
            val next = offsets.drop(1).toIntArray()
            if (!next.contentEquals(boundaries.value)) boundaries.value = next
            layout(constraints.constrainWidth(x), height) {
                placeables.forEachIndexed { index, placeable -> placeable.placeRelative(offsets[index], 0) }
            }
        }
    }
}
```

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

import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback

val LocalHapticsEnabled = staticCompositionLocalOf { true }

@Composable
fun ProvideHaptics(enabled: Boolean, content: @Composable () -> Unit) {
    CompositionLocalProvider(LocalHapticsEnabled provides enabled, content = content)
}

internal class Haptics(private val feedback: HapticFeedback, private val enabled: Boolean) {
    fun perform(type: HapticFeedbackType) {
        if (enabled) feedback.performHapticFeedback(type)
    }

    fun toggle(on: Boolean) = perform(if (on) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff)

    fun selection() = perform(HapticFeedbackType.SegmentTick)
}

@Composable
internal fun rememberHaptics(): Haptics {
    val feedback = LocalHapticFeedback.current
    val enabled = LocalHapticsEnabled.current
    return remember(feedback, enabled) { Haptics(feedback, enabled) }
}
```

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

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

@Composable
fun Label(
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
    content: @Composable RowScope.() -> Unit,
) {
    val colors = EsuissTheme.colors
    val color = if (enabled) colors.label else colors.labelQuaternary
    Row(
        modifier = modifier,
        horizontalArrangement = Arrangement.spacedBy(8.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        CompositionLocalProvider(
            LocalContentStyle provides Typography.base.copy(color = color, fontWeight = FontWeight.SemiBold),
            LocalContentColor provides color,
        ) {
            content()
        }
    }
}

@Composable
fun Label(
    text: String,
    modifier: Modifier = Modifier,
    enabled: Boolean = true,
) {
    Label(modifier, enabled) {
        BasicText(text, style = LocalContentStyle.current)
    }
}
```

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

import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.esuiss.ui.theme.EsuissTheme

@Composable
fun Separator(
    modifier: Modifier = Modifier,
    orientation: Orientation = Orientation.Horizontal,
) {
    val sizing = if (orientation == Orientation.Horizontal) {
        Modifier.fillMaxWidth().height(1.dp)
    } else {
        Modifier.fillMaxHeight().width(1.dp)
    }
    Box(modifier.then(sizing).background(EsuissTheme.colors.separator))
}
```

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

## Usage

```tsx
import { Slider } from "@/components/ui/slider"
```

```tsx
<Slider defaultValue={50} max={100} step={1} accessibilityLabel="Volume" />
```

Use `defaultValue` for an uncontrolled slider, or `value` with `onValueChange` to control it. `onValueCommitted` runs when a drag or track press ends, which suits saving or fetching.

Set `min`, `max` and `step` to define the range; `min` defaults to `0` and `max` to `100`. For ranges, `minStepsBetweenValues` keeps thumbs apart and `thumbCollisionBehavior` decides whether thumbs `push` each other, `swap`, or stop (`none`).

The number of thumbs comes from the array length of `value` or `defaultValue`.

## Examples

### Controlled

A controlled slider that shows the live and committed values.

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

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

function ControlledDemo() {
  const [value, setValue] = React.useState(30)
  const [committed, setCommitted] = React.useState(30)
  return (
    <View className="w-full gap-3">
      <View className="flex-row items-center justify-between">
        <Text className="text-sm font-medium">Brightness</Text>
        <Text className="text-sm text-label-secondary">
          {value} / committed {committed}
        </Text>
      </View>
      <Slider
        value={value}
        onValueChange={(next) => setValue(next)}
        onValueCommitted={(next) => setCommitted(next)}
        accessibilityLabel="Brightness"
      />
    </View>
  )
}

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

```swift
import EsuissUI
import SwiftUI

struct SliderControlledDemo: View {
  @State private var value: Double = 30
  @State private var committed: Double = 30

  var body: some View {
    VStack(alignment: .leading, spacing: 12) {
      HStack {
        Text("Brightness")
          .textStyle(.sm, weight: .medium)
          .foregroundStyle(Color.label)
        Spacer()
        Text(verbatim: "\(Int(value)) / committed \(Int(committed))")
          .textStyle(.sm)
          .foregroundStyle(Color.labelSecondary)
      }
      ValueSlider(value: $value, in: 0...100, step: 1) { editing in
        if !editing { committed = value }
      }
      .accessibilityLabel(Text("Brightness"))
    }
    .frame(maxWidth: .infinity, alignment: .leading)
  }
}
```

```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.mutableFloatStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Slider
import com.esuiss.ui.components.Text
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography
import kotlin.math.roundToInt

@Composable
fun SliderHeader(title: String, detail: String) {
    Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
        Text(title, style = Typography.sm.copy(fontWeight = FontWeight.Medium))
        Text(detail, style = Typography.sm, color = EsuissTheme.colors.labelSecondary)
    }
}

@Composable
fun SliderControlled() {
    var value by rememberSaveable { mutableFloatStateOf(30f) }
    var committed by rememberSaveable { mutableFloatStateOf(30f) }
    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
        SliderHeader("Brightness", "${value.roundToInt()} / committed ${committed.roundToInt()}")
        Slider(
            value,
            { value = it },
            Modifier.fillMaxWidth(),
            onValueChangeFinished = { committed = value },
            contentDescription = "Brightness",
        )
    }
}
```

### Range

A two-thumb price range with a step and a minimum gap.

![Preview](https://uim.suiss.app/previews/components-slider--range.light.webp)

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

function RangeDemo() {
  const [value, setValue] = React.useState<readonly number[]>([25, 75])
  return (
    <View className="w-full gap-3">
      <View className="flex-row items-center justify-between">
        <Text className="text-sm font-medium">Price</Text>
        <Text className="text-sm text-label-secondary">
          ${value[0]} – ${value[1]}
        </Text>
      </View>
      <Slider
        value={value}
        onValueChange={setValue}
        step={5}
        minStepsBetweenValues={2}
        accessibilityLabel="Price"
      />
    </View>
  )
}

export function SliderRangeExample() {
  return (
    <RangeDemo />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct SliderRangeDemo: View {
  @State private var value: ClosedRange<Double> = 25...75

  var body: some View {
    VStack(alignment: .leading, spacing: 12) {
      HStack {
        Text("Price")
          .textStyle(.sm, weight: .medium)
          .foregroundStyle(Color.label)
        Spacer()
        Text(verbatim: "$\(Int(value.lowerBound)) – $\(Int(value.upperBound))")
          .textStyle(.sm)
          .foregroundStyle(Color.labelSecondary)
      }
      RangeSlider(value: $value, in: 0...100, step: 5)
        .accessibilityLabel(Text("Price"))
    }
    .frame(maxWidth: .infinity, alignment: .leading)
  }
}
```

```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.mutableFloatStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.RangeSlider
import com.esuiss.ui.components.Text
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography
import kotlin.math.roundToInt

@Composable
fun SliderHeader(title: String, detail: String) {
    Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
        Text(title, style = Typography.sm.copy(fontWeight = FontWeight.Medium))
        Text(detail, style = Typography.sm, color = EsuissTheme.colors.labelSecondary)
    }
}

@Composable
fun SliderRange() {
    var start by rememberSaveable { mutableFloatStateOf(25f) }
    var end by rememberSaveable { mutableFloatStateOf(75f) }
    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
        SliderHeader("Price", "$${start.roundToInt()} – $${end.roundToInt()}")
        RangeSlider(
            start..end,
            {
                start = it.start
                end = it.endInclusive
            },
            Modifier.fillMaxWidth(),
            step = 5f,
            contentDescription = "Price",
        )
    }
}
```

### Multiple thumbs

Three thumbs that swap when they cross.

![Preview](https://uim.suiss.app/previews/components-slider--multiple-thumbs.light.webp)

```tsx
import { Slider } from "@/components/ui/slider"

export function SliderMultipleThumbsExample() {
  return (
    <Slider defaultValue={[10, 40, 80]} max={100} step={1} accessibilityLabel="Volume" thumbCollisionBehavior="swap" />
  )
}
```

### Step

A slider that moves in decimal steps.

![Preview](https://uim.suiss.app/previews/components-slider--step.light.webp)

```tsx
import { Slider } from "@/components/ui/slider"

export function SliderStepExample() {
  return (
    <Slider defaultValue={0.5} max={1} step={0.1} accessibilityLabel="Volume" min={0} />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct SliderStepExample: View {
  var body: some View {
    SliderDemo(initial: 0.5, bounds: 0...1, step: 0.1)
  }
}

struct SliderDemo: View {
  var bounds: ClosedRange<Double> = 0...100
  var step: Double = 1

  @State private var value: Double

  init(initial: Double, bounds: ClosedRange<Double> = 0...100, step: Double = 1) {
    self.bounds = bounds
    self.step = step
    _value = State(initialValue: initial)
  }

  var body: some View {
    ValueSlider(value: $value, in: bounds, step: step)
      .accessibilityLabel(Text("Volume"))
      .frame(maxWidth: .infinity)
  }
}
```

```kotlin
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import com.esuiss.ui.components.Slider

@Composable
fun SliderStepExample() {
    SliderUncontrolled(0.5f, valueRange = 0f..1f, step = 0.1f)
}

@Composable
fun SliderUncontrolled(
    initial: Float,
    valueRange: ClosedFloatingPointRange<Float> = 0f..100f,
    step: Float = 1f,
    enabled: Boolean = true,
) {
    var value by rememberSaveable { mutableFloatStateOf(initial) }
    Slider(
        value,
        { value = it },
        Modifier.fillMaxWidth(),
        enabled = enabled,
        valueRange = valueRange,
        step = step,
        contentDescription = "Volume",
    )
}
```

### Vertical

Single and range sliders in vertical orientation.

![Preview](https://uim.suiss.app/previews/components-slider--vertical.light.webp)

```tsx
import { View } from "react-native"
import { Slider } from "@/components/ui/slider"

export function SliderVerticalExample() {
  return (
    <View className="h-48 flex-row justify-center gap-8">
      <Slider defaultValue={40} orientation="vertical" />
      <Slider defaultValue={[20, 70]} orientation="vertical" />
    </View>
  )
}
```

### Disabled

A disabled slider with muted colors.

![Preview](https://uim.suiss.app/previews/components-slider--disabled.light.webp)

```tsx
import { Slider } from "@/components/ui/slider"

export function SliderDisabledExample() {
  return (
    <Slider defaultValue={40} max={100} step={1} accessibilityLabel="Volume" disabled />
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct SliderDisabledExample: View {
  var body: some View {
    SliderDemo(initial: 40)
      .disabled(true)
  }
}

struct SliderDemo: View {
  var bounds: ClosedRange<Double> = 0...100
  var step: Double = 1

  @State private var value: Double

  init(initial: Double, bounds: ClosedRange<Double> = 0...100, step: Double = 1) {
    self.bounds = bounds
    self.step = step
    _value = State(initialValue: initial)
  }

  var body: some View {
    ValueSlider(value: $value, in: bounds, step: step)
      .accessibilityLabel(Text("Volume"))
      .frame(maxWidth: .infinity)
  }
}
```

```kotlin
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import com.esuiss.ui.components.Slider

@Composable
fun SliderDisabledExample() {
    SliderUncontrolled(40f, enabled = false)
}

@Composable
fun SliderUncontrolled(
    initial: Float,
    valueRange: ClosedFloatingPointRange<Float> = 0f..100f,
    step: Float = 1f,
    enabled: Boolean = true,
) {
    var value by rememberSaveable { mutableFloatStateOf(initial) }
    Slider(
        value,
        { value = it },
        Modifier.fillMaxWidth(),
        enabled = enabled,
        valueRange = valueRange,
        step = step,
        contentDescription = "Volume",
    )
}
```

## API Reference

### Slider

## Accessibility

- Each thumb has the `adjustable` role with its min, max and current value, so VoiceOver and TalkBack users can swipe up or down to change it.
- `accessibilityLabel` on `Slider` is passed to every thumb; set it, since the slider has no visible label.
- Disabled thumbs report `disabled` in their accessibility state.

## Notes

- The track uses `bg-label-quaternary` and the indicator `bg-accent`, with `bg-control-disabled` and `bg-accent-disabled` when disabled.
- Thumbs use `bg-surface` with a shadow, or `bg-label` in dark mode.
- Light and dark colors follow the system appearance.
