# Header

A navigation bar with a back button, a centered title, actions and an optional large title that collapses on scroll.

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

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

**React Native, SwiftUI, Jetpack Compose**

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

```tsx
import { View } from "react-native"
import { Button } from "@/components/ui/button"
import { Header, HeaderActions, HeaderBack, HeaderTitle } from "@/components/ui/header"
import { Text } from "@/components/ui/text"

export function HeaderExample() {
  return (
    <View className="-mx-4 -mt-4">
      <Header>
        <HeaderBack>Settings</HeaderBack>
        <HeaderTitle>Profile</HeaderTitle>
        <HeaderActions>
          <Button variant="plain" size="sm">
            <Text className="font-semibold">Edit</Text>
          </Button>
        </HeaderActions>
      </Header>
    </View>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct HeaderExample: View {
  var body: some View {
    Header {
      HeaderTitle("Profile")
    } leading: {
      HeaderBack("Settings")
    } trailing: {
      HeaderActions {
        Button {} label: {
          Text("Edit").fontWeight(.semibold)
        }
        .buttonStyle(.variant(.plain, size: .sm))
      }
    }
    .headerStoryBleed()
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layout
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonSize
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Header
import com.esuiss.ui.components.HeaderActions
import com.esuiss.ui.components.HeaderBack
import com.esuiss.ui.components.HeaderTitle
import com.esuiss.ui.components.Text

fun Modifier.headerBleed(): Modifier = layout { measurable, constraints ->
    val inset = 16.dp.roundToPx()
    val width = constraints.maxWidth + inset * 2
    val placeable = measurable.measure(constraints.copy(minWidth = width, maxWidth = width))
    layout(constraints.maxWidth, (placeable.height - inset).coerceAtLeast(0)) {
        placeable.place(-inset, -inset)
    }
}

@Composable
fun HeaderPlainAction(text: String) {
    Button(onClick = {}, variant = ButtonVariant.Plain, size = ButtonSize.Sm) {
        Text(text, style = TextStyle(fontWeight = FontWeight.SemiBold))
    }
}

@Composable
fun HeaderDefault() {
    Column(Modifier.headerBleed()) {
        Header {
            HeaderBack(onClick = {}, text = "Settings")
            HeaderTitle("Profile")
            HeaderActions { HeaderPlainAction("Edit") }
        }
    }
}
```

`Header` is a 44-point bar that sorts its children into three slots: `HeaderBack` and leading `HeaderActions` on the start side, `HeaderTitle` in the center, and everything else on the end side. The side slots share the remaining width equally, so the title stays centered.

For a large title, create a scroll state with `useHeaderScroll()`, pass it to both `Header` and `HeaderScrollView`, and put a `HeaderLargeTitle` at the top of the scroll view. As the large title scrolls away, the small title fades in and a hairline appears under the bar. Pulling down stretches the large title slightly.

## Installation

**React Native**

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

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

**SwiftUI**

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

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add header --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). On React Native it also adds the animated view `components/ui/animated-view.tsx`.

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install 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 animated view `components/ui/animated-view.tsx` from the next step.

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

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

4. Copy the source into `components/ui/header.tsx`:

```tsx title="uniwind/components/ui/header.tsx"
import { cn } from "cn"
import { CaretLeftIcon } from "phosphor-react-native"
import * as React from "react"
import {
  I18nManager,
  Pressable,
  type ScrollViewProps,
  View,
  type ViewProps,
} from "react-native"
import Animated, {
  interpolate,
  type SharedValue,
  useAnimatedScrollHandler,
  useAnimatedStyle,
  useSharedValue,
} from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

type HeaderScroll = {
  scrollY: SharedValue<number>
  largeTitleHeight: SharedValue<number>
  onScroll: ReturnType<typeof useAnimatedScrollHandler>
}

const HeaderContext = React.createContext<HeaderScroll | null>(null)

function useHeaderScroll(): HeaderScroll {
  const scrollY = useSharedValue(0)
  const largeTitleHeight = useSharedValue(0)
  const onScroll = useAnimatedScrollHandler((event) => {
    scrollY.value = event.contentOffset.y
  })
  return React.useMemo(
    () => ({ scrollY, largeTitleHeight, onScroll }),
    [scrollY, largeTitleHeight, onScroll]
  )
}

function wrapText(children: React.ReactNode, className: string) {
  if (typeof children === "string" || typeof children === "number") {
    return (
      <Text numberOfLines={1} className={className}>
        {children}
      </Text>
    )
  }
  return children
}

function Header({
  scroll,
  className,
  children,
  ...props
}: ViewProps & {
  scroll?: HeaderScroll
}) {
  const leading: React.ReactNode[] = []
  const trailing: React.ReactNode[] = []
  const title: React.ReactNode[] = []

  for (const child of React.Children.toArray(children)) {
    if (React.isValidElement<{ side?: "leading" | "trailing" }>(child)) {
      if (child.type === HeaderTitle) {
        title.push(child)
        continue
      }
      if (
        child.type === HeaderBack ||
        (child.type === HeaderActions && child.props.side === "leading")
      ) {
        leading.push(child)
        continue
      }
    }
    trailing.push(child)
  }

  const scrollY = scroll?.scrollY

  const largeTitleHeight = scroll?.largeTitleHeight

  const borderStyle = useAnimatedStyle(() => {
    if (!scrollY || !largeTitleHeight) return { opacity: 0 }
    return {
      opacity: interpolate(
        scrollY.value - largeTitleHeight.value,
        [0, 8],
        [0, 1],
        "clamp"
      ),
    }
  })

  return (
    <HeaderContext.Provider value={scroll ?? null}>
      <View
        className={cn("h-11 flex-row items-center px-2", className)}
        {...props}
      >
        <View className="min-w-11 flex-1 basis-0 flex-row items-center justify-start">
          {leading}
        </View>
        {title}
        <View className="min-w-11 flex-1 basis-0 flex-row items-center justify-end">
          {trailing}
        </View>
        <AnimatedView
          className="absolute inset-x-0 bottom-0 h-px bg-border"
          style={[{ pointerEvents: "none" }, borderStyle]}
        />
      </View>
    </HeaderContext.Provider>
  )
}

function HeaderTitle({
  subtitle,
  className,
  style,
  children,
  ...props
}: ViewProps & {
  subtitle?: React.ReactNode
}) {
  const scroll = React.useContext(HeaderContext)
  const scrollY = scroll?.scrollY
  const largeTitleHeight = scroll?.largeTitleHeight
  const animatedStyle = useAnimatedStyle(() => {
    if (!scrollY || !largeTitleHeight || largeTitleHeight.value <= 0)
      return { opacity: 1 }
    const height = largeTitleHeight.value
    return {
      opacity: interpolate(
        scrollY.value,
        [height * 0.6, height],
        [0, 1],
        "clamp"
      ),
    }
  })
  return (
    <AnimatedView
      role="heading"
      {...props}
      className={cn("shrink items-center px-2", className)}
      style={[style, animatedStyle]}
    >
      {wrapText(children, "text-base font-semibold")}
      {subtitle !== undefined
        ? wrapText(subtitle, "text-xs text-muted-foreground")
        : null}
    </AnimatedView>
  )
}

function HeaderLargeTitle({
  subtitle,
  className,
  style,
  children,
  onLayout,
  ...props
}: ViewProps & {
  subtitle?: React.ReactNode
}) {
  const scroll = React.useContext(HeaderContext)
  const scrollY = scroll?.scrollY
  const largeTitleHeight = scroll?.largeTitleHeight

  React.useEffect(() => {
    if (!scroll) return
    return () => {
      scroll.largeTitleHeight.value = 0
    }
  }, [scroll])

  const animatedStyle = useAnimatedStyle(() => {
    if (!scrollY || !largeTitleHeight) return {}
    return {
      transform: [
        {
          scale: interpolate(scrollY.value, [-160, 0], [1.12, 1], "clamp"),
        },
      ],
    }
  })

  return (
    <AnimatedView
      {...props}
      className={cn("gap-0.5 px-4 pt-0.5 pb-2", className)}
      style={[
        { transformOrigin: I18nManager.isRTL ? "right" : "left" },
        style,
        animatedStyle,
      ]}
      onLayout={(event) => {
        if (scroll) {
          scroll.largeTitleHeight.value = event.nativeEvent.layout.height
        }
        onLayout?.(event)
      }}
    >
      {typeof children === "string" || typeof children === "number" ? (
        <Text role="heading" className="text-3xl font-bold">
          {children}
        </Text>
      ) : (
        children
      )}
      {subtitle !== undefined
        ? wrapText(subtitle, "text-sm text-muted-foreground")
        : null}
    </AnimatedView>
  )
}

function HeaderBack({
  className,
  children,
  accessibilityLabel,
  ...props
}: Omit<React.ComponentProps<typeof Pressable>, "children"> & {
  children?: React.ReactNode
}) {
  return (
    <Pressable
      role="button"
      hitSlop={4}
      accessibilityLabel={
        accessibilityLabel ??
        (typeof children === "string" ? children : undefined) ??
        "Back"
      }
      className={cn(
        "h-9 shrink flex-row items-center gap-0.5 rounded-full ps-0.5 pe-3 active:opacity-80",
        children === undefined && "pe-0.5",
        className
      )}
      {...props}
    >
      <Icon
        as={CaretLeftIcon}
        weight="bold"
        className={cn("size-6 text-primary", I18nManager.isRTL && "rotate-180")}
      />
      {wrapText(children, "shrink text-base text-primary")}
    </Pressable>
  )
}

function HeaderActions({
  side = "trailing",
  className,
  ...props
}: ViewProps & {
  side?: "leading" | "trailing"
}) {
  return (
    <View
      className={cn(
        "flex-row items-center gap-1",
        side === "leading" ? "justify-start" : "justify-end",
        className
      )}
      {...props}
    />
  )
}

function HeaderScrollView({
  scroll,
  className,
  scrollEventThrottle = 16,
  ...props
}: Omit<ScrollViewProps, "onScroll"> & {
  scroll: HeaderScroll
}) {
  return (
    <HeaderContext.Provider value={scroll}>
      <Animated.ScrollView
        onScroll={scroll.onScroll}
        scrollEventThrottle={scrollEventThrottle}
        className={cn("flex-1", className)}
        {...props}
      />
    </HeaderContext.Provider>
  )
}

export type { HeaderScroll }
export {
  Header,
  HeaderActions,
  HeaderBack,
  HeaderLargeTitle,
  HeaderScrollView,
  HeaderTitle,
  useHeaderScroll,
}
```

5. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install 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 animated view `components/ui/animated-view.tsx` from the next step.

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

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

4. Copy the source into `components/ui/header.tsx`:

```tsx title="nativewind/components/ui/header.tsx"
import { cn } from "cn"
import { CaretLeftIcon } from "phosphor-react-native"
import * as React from "react"
import {
  I18nManager,
  Pressable,
  type ScrollViewProps,
  View,
  type ViewProps,
} from "react-native"
import Animated, {
  interpolate,
  type SharedValue,
  useAnimatedScrollHandler,
  useAnimatedStyle,
  useSharedValue,
} from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

type HeaderScroll = {
  scrollY: SharedValue<number>
  largeTitleHeight: SharedValue<number>
  onScroll: ReturnType<typeof useAnimatedScrollHandler>
}

const HeaderContext = React.createContext<HeaderScroll | null>(null)

function useHeaderScroll(): HeaderScroll {
  const scrollY = useSharedValue(0)
  const largeTitleHeight = useSharedValue(0)
  const onScroll = useAnimatedScrollHandler((event) => {
    scrollY.value = event.contentOffset.y
  })
  return React.useMemo(
    () => ({ scrollY, largeTitleHeight, onScroll }),
    [scrollY, largeTitleHeight, onScroll]
  )
}

function wrapText(children: React.ReactNode, className: string) {
  if (typeof children === "string" || typeof children === "number") {
    return (
      <Text numberOfLines={1} className={className}>
        {children}
      </Text>
    )
  }
  return children
}

function Header({
  scroll,
  className,
  children,
  ...props
}: ViewProps & {
  scroll?: HeaderScroll
}) {
  const leading: React.ReactNode[] = []
  const trailing: React.ReactNode[] = []
  const title: React.ReactNode[] = []

  for (const child of React.Children.toArray(children)) {
    if (React.isValidElement<{ side?: "leading" | "trailing" }>(child)) {
      if (child.type === HeaderTitle) {
        title.push(child)
        continue
      }
      if (
        child.type === HeaderBack ||
        (child.type === HeaderActions && child.props.side === "leading")
      ) {
        leading.push(child)
        continue
      }
    }
    trailing.push(child)
  }

  const scrollY = scroll?.scrollY

  const largeTitleHeight = scroll?.largeTitleHeight

  const borderStyle = useAnimatedStyle(() => {
    if (!scrollY || !largeTitleHeight) return { opacity: 0 }
    return {
      opacity: interpolate(
        scrollY.value - largeTitleHeight.value,
        [0, 8],
        [0, 1],
        "clamp"
      ),
    }
  })

  return (
    <HeaderContext.Provider value={scroll ?? null}>
      <View
        className={cn("h-11 flex-row items-center px-2", className)}
        {...props}
      >
        <View className="min-w-11 flex-1 basis-0 flex-row items-center justify-start">
          {leading}
        </View>
        {title}
        <View className="min-w-11 flex-1 basis-0 flex-row items-center justify-end">
          {trailing}
        </View>
        <AnimatedView
          className="absolute inset-x-0 bottom-0 h-px bg-border"
          style={[{ pointerEvents: "none" }, borderStyle]}
        />
      </View>
    </HeaderContext.Provider>
  )
}

function HeaderTitle({
  subtitle,
  className,
  style,
  children,
  ...props
}: ViewProps & {
  subtitle?: React.ReactNode
}) {
  const scroll = React.useContext(HeaderContext)
  const scrollY = scroll?.scrollY
  const largeTitleHeight = scroll?.largeTitleHeight
  const animatedStyle = useAnimatedStyle(() => {
    if (!scrollY || !largeTitleHeight || largeTitleHeight.value <= 0)
      return { opacity: 1 }
    const height = largeTitleHeight.value
    return {
      opacity: interpolate(
        scrollY.value,
        [height * 0.6, height],
        [0, 1],
        "clamp"
      ),
    }
  })
  return (
    <AnimatedView
      role="heading"
      {...props}
      className={cn("shrink items-center px-2", className)}
      style={[style, animatedStyle]}
    >
      {wrapText(children, "text-base font-semibold")}
      {subtitle !== undefined
        ? wrapText(subtitle, "text-xs text-muted-foreground")
        : null}
    </AnimatedView>
  )
}

function HeaderLargeTitle({
  subtitle,
  className,
  style,
  children,
  onLayout,
  ...props
}: ViewProps & {
  subtitle?: React.ReactNode
}) {
  const scroll = React.useContext(HeaderContext)
  const scrollY = scroll?.scrollY
  const largeTitleHeight = scroll?.largeTitleHeight

  React.useEffect(() => {
    if (!scroll) return
    return () => {
      scroll.largeTitleHeight.value = 0
    }
  }, [scroll])

  const animatedStyle = useAnimatedStyle(() => {
    if (!scrollY || !largeTitleHeight) return {}
    return {
      transform: [
        {
          scale: interpolate(scrollY.value, [-160, 0], [1.12, 1], "clamp"),
        },
      ],
    }
  })

  return (
    <AnimatedView
      {...props}
      className={cn("gap-0.5 px-4 pt-0.5 pb-2", className)}
      style={[
        { transformOrigin: I18nManager.isRTL ? "right" : "left" },
        style,
        animatedStyle,
      ]}
      onLayout={(event) => {
        if (scroll) {
          scroll.largeTitleHeight.value = event.nativeEvent.layout.height
        }
        onLayout?.(event)
      }}
    >
      {typeof children === "string" || typeof children === "number" ? (
        <Text role="heading" className="text-3xl font-bold">
          {children}
        </Text>
      ) : (
        children
      )}
      {subtitle !== undefined
        ? wrapText(subtitle, "text-sm text-muted-foreground")
        : null}
    </AnimatedView>
  )
}

function HeaderBack({
  className,
  children,
  accessibilityLabel,
  ...props
}: Omit<React.ComponentProps<typeof Pressable>, "children"> & {
  children?: React.ReactNode
}) {
  return (
    <Pressable
      role="button"
      hitSlop={4}
      accessibilityLabel={
        accessibilityLabel ??
        (typeof children === "string" ? children : undefined) ??
        "Back"
      }
      className={cn(
        "h-9 shrink flex-row items-center gap-0.5 rounded-full ps-0.5 pe-3 active:opacity-80",
        children === undefined && "pe-0.5",
        className
      )}
      {...props}
    >
      <Icon
        as={CaretLeftIcon}
        weight="bold"
        className={cn("size-6 text-primary", I18nManager.isRTL && "rotate-180")}
      />
      {wrapText(children, "shrink text-base text-primary")}
    </Pressable>
  )
}

function HeaderActions({
  side = "trailing",
  className,
  ...props
}: ViewProps & {
  side?: "leading" | "trailing"
}) {
  return (
    <View
      className={cn(
        "flex-row items-center gap-1",
        side === "leading" ? "justify-start" : "justify-end",
        className
      )}
      {...props}
    />
  )
}

function HeaderScrollView({
  scroll,
  className,
  scrollEventThrottle = 16,
  ...props
}: Omit<ScrollViewProps, "onScroll"> & {
  scroll: HeaderScroll
}) {
  return (
    <HeaderContext.Provider value={scroll}>
      <Animated.ScrollView
        onScroll={scroll.onScroll}
        scrollEventThrottle={scrollEventThrottle}
        className={cn("flex-1", className)}
        {...props}
      />
    </HeaderContext.Provider>
  )
}

export type { HeaderScroll }
export {
  Header,
  HeaderActions,
  HeaderBack,
  HeaderLargeTitle,
  HeaderScrollView,
  HeaderTitle,
  useHeaderScroll,
}
```

5. 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 animated view `components/ui/animated-view.tsx` from the next step.

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

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

4. Copy the source into `components/ui/header.tsx`:

```tsx title="stylesheet/components/ui/header.tsx"
import { CaretLeftIcon } from "phosphor-react-native"
import * as React from "react"
import {
  I18nManager,
  Pressable,
  type ScrollViewProps,
  type StyleProp,
  StyleSheet,
  type TextStyle,
  View,
  type ViewProps,
  type ViewStyle,
} from "react-native"
import Animated, {
  interpolate,
  type SharedValue,
  useAnimatedScrollHandler,
  useAnimatedStyle,
  useSharedValue,
} from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"
import { fontSize, radius, useTheme } from "@/components/ui/theme"

type HeaderScroll = {
  scrollY: SharedValue<number>
  largeTitleHeight: SharedValue<number>
  onScroll: ReturnType<typeof useAnimatedScrollHandler>
}

const HeaderContext = React.createContext<HeaderScroll | null>(null)

function useHeaderScroll(): HeaderScroll {
  const scrollY = useSharedValue(0)
  const largeTitleHeight = useSharedValue(0)
  const onScroll = useAnimatedScrollHandler((event) => {
    scrollY.value = event.contentOffset.y
  })
  return React.useMemo(
    () => ({ scrollY, largeTitleHeight, onScroll }),
    [scrollY, largeTitleHeight, onScroll]
  )
}

const styles = StyleSheet.create({
  bar: {
    height: 44,
    flexDirection: "row",
    alignItems: "center",
    paddingHorizontal: 8,
  },
  slot: {
    minWidth: 44,
    flex: 1,
    flexBasis: 0,
    flexDirection: "row",
    alignItems: "center",
  },
  border: {
    position: "absolute",
    left: 0,
    right: 0,
    bottom: 0,
    height: 1,
    pointerEvents: "none",
  },
  title: { flexShrink: 1, alignItems: "center", paddingHorizontal: 8 },
  titleText: { ...fontSize.base, fontWeight: "600" },
  subtitleText: { ...fontSize.xs },
  largeTitle: {
    gap: 2,
    paddingHorizontal: 16,
    paddingTop: 2,
    paddingBottom: 8,
  },
  largeTitleText: { ...fontSize["3xl"], fontWeight: "700" },
  largeSubtitleText: { ...fontSize.sm },
  back: {
    height: 36,
    flexShrink: 1,
    flexDirection: "row",
    alignItems: "center",
    gap: 2,
    borderRadius: radius.full,
    paddingStart: 2,
    paddingEnd: 12,
  },
  backIconOnly: { paddingEnd: 2 },
  backText: { ...fontSize.base, flexShrink: 1 },
  flipped: { transform: [{ rotate: "180deg" }] },
  actions: { flexDirection: "row", alignItems: "center", gap: 4 },
  scroll: { flex: 1 },
})

function wrapText(children: React.ReactNode, style: TextStyle) {
  if (typeof children === "string" || typeof children === "number") {
    return (
      <Text numberOfLines={1} style={style}>
        {children}
      </Text>
    )
  }
  return children
}

function Header({
  scroll,
  style,
  children,
  ...props
}: ViewProps & {
  scroll?: HeaderScroll
}) {
  const { colors } = useTheme()
  const leading: React.ReactNode[] = []
  const trailing: React.ReactNode[] = []
  const title: React.ReactNode[] = []

  for (const child of React.Children.toArray(children)) {
    if (React.isValidElement<{ side?: "leading" | "trailing" }>(child)) {
      if (child.type === HeaderTitle) {
        title.push(child)
        continue
      }
      if (
        child.type === HeaderBack ||
        (child.type === HeaderActions && child.props.side === "leading")
      ) {
        leading.push(child)
        continue
      }
    }
    trailing.push(child)
  }

  const scrollY = scroll?.scrollY

  const largeTitleHeight = scroll?.largeTitleHeight

  const borderStyle = useAnimatedStyle(() => {
    if (!scrollY || !largeTitleHeight) return { opacity: 0 }
    return {
      opacity: interpolate(
        scrollY.value - largeTitleHeight.value,
        [0, 8],
        [0, 1],
        "clamp"
      ),
    }
  })

  return (
    <HeaderContext.Provider value={scroll ?? null}>
      <View style={[styles.bar, style]} {...props}>
        <View style={[styles.slot, { justifyContent: "flex-start" }]}>
          {leading}
        </View>
        {title}
        <View style={[styles.slot, { justifyContent: "flex-end" }]}>
          {trailing}
        </View>
        <AnimatedView
          style={[
            styles.border,
            { backgroundColor: colors.border },
            borderStyle,
          ]}
        />
      </View>
    </HeaderContext.Provider>
  )
}

function HeaderTitle({
  subtitle,
  style,
  children,
  ...props
}: ViewProps & {
  subtitle?: React.ReactNode
}) {
  const { colors } = useTheme()
  const scroll = React.useContext(HeaderContext)
  const scrollY = scroll?.scrollY
  const largeTitleHeight = scroll?.largeTitleHeight
  const animatedStyle = useAnimatedStyle(() => {
    if (!scrollY || !largeTitleHeight || largeTitleHeight.value <= 0)
      return { opacity: 1 }
    const height = largeTitleHeight.value
    return {
      opacity: interpolate(
        scrollY.value,
        [height * 0.6, height],
        [0, 1],
        "clamp"
      ),
    }
  })
  return (
    <AnimatedView
      role="heading"
      style={[styles.title, animatedStyle, style]}
      {...props}
    >
      {wrapText(children, styles.titleText)}
      {subtitle !== undefined
        ? wrapText(subtitle, {
            ...styles.subtitleText,
            color: colors.mutedForeground,
          })
        : null}
    </AnimatedView>
  )
}

function HeaderLargeTitle({
  subtitle,
  style,
  children,
  onLayout,
  ...props
}: ViewProps & {
  subtitle?: React.ReactNode
}) {
  const { colors } = useTheme()
  const scroll = React.useContext(HeaderContext)
  const scrollY = scroll?.scrollY
  const largeTitleHeight = scroll?.largeTitleHeight

  React.useEffect(() => {
    if (!scroll) return
    return () => {
      scroll.largeTitleHeight.value = 0
    }
  }, [scroll])

  const animatedStyle = useAnimatedStyle(() => {
    if (!scrollY || !largeTitleHeight) return {}
    return {
      transform: [
        {
          scale: interpolate(scrollY.value, [-160, 0], [1.12, 1], "clamp"),
        },
      ],
    }
  })

  return (
    <AnimatedView
      style={[
        styles.largeTitle,
        { transformOrigin: I18nManager.isRTL ? "right" : "left" },
        animatedStyle,
        style,
      ]}
      onLayout={(event) => {
        if (scroll) {
          scroll.largeTitleHeight.value = event.nativeEvent.layout.height
        }
        onLayout?.(event)
      }}
      {...props}
    >
      {typeof children === "string" || typeof children === "number" ? (
        <Text role="heading" style={styles.largeTitleText}>
          {children}
        </Text>
      ) : (
        children
      )}
      {subtitle !== undefined
        ? wrapText(subtitle, {
            ...styles.largeSubtitleText,
            color: colors.mutedForeground,
          })
        : null}
    </AnimatedView>
  )
}

function HeaderBack({
  style,
  children,
  accessibilityLabel,
  ...props
}: Omit<React.ComponentProps<typeof Pressable>, "children" | "style"> & {
  children?: React.ReactNode
  style?: StyleProp<ViewStyle>
}) {
  const { colors } = useTheme()
  return (
    <Pressable
      role="button"
      hitSlop={4}
      accessibilityLabel={
        accessibilityLabel ??
        (typeof children === "string" ? children : undefined) ??
        "Back"
      }
      style={({ pressed }) => [
        styles.back,
        children === undefined && styles.backIconOnly,
        pressed && { opacity: 0.8 },
        style,
      ]}
      {...props}
    >
      <Icon
        as={CaretLeftIcon}
        weight="bold"
        size={24}
        color={colors.primary}
        style={I18nManager.isRTL ? styles.flipped : undefined}
      />
      {wrapText(children, { ...styles.backText, color: colors.primary })}
    </Pressable>
  )
}

function HeaderActions({
  side = "trailing",
  style,
  ...props
}: ViewProps & {
  side?: "leading" | "trailing"
}) {
  return (
    <View
      style={[
        styles.actions,
        { justifyContent: side === "leading" ? "flex-start" : "flex-end" },
        style,
      ]}
      {...props}
    />
  )
}

function HeaderScrollView({
  scroll,
  style,
  scrollEventThrottle = 16,
  ...props
}: Omit<ScrollViewProps, "onScroll"> & {
  scroll: HeaderScroll
}) {
  return (
    <HeaderContext.Provider value={scroll}>
      <Animated.ScrollView
        onScroll={scroll.onScroll}
        scrollEventThrottle={scrollEventThrottle}
        style={[styles.scroll, style]}
        {...props}
      />
    </HeaderContext.Provider>
  )
}

export type { HeaderScroll }
export {
  Header,
  HeaderActions,
  HeaderBack,
  HeaderLargeTitle,
  HeaderScrollView,
  HeaderTitle,
  useHeaderScroll,
}
```

5. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

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

3. Copy the theme files every view uses:

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

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

4. Copy the source and every file it uses:

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

extension EnvironmentValues {
  @Entry var headerProgress: CGFloat?
  @Entry var headerReport: ((CGFloat) -> Void)?
}

enum HeaderMath {
  static func progress(minY: CGFloat, height: CGFloat) -> CGFloat {
    guard height > 0 else { return 0 }
    return min(max(-minY / height, 0), 1)
  }
}

public struct Header<Title: View, Leading: View, Trailing: View>: View {
  let title: Title
  let leading: Leading
  let trailing: Trailing

  @Environment(\.headerProgress) private var progress
  @Environment(\.screenBackground) private var background

  public init(
    @ViewBuilder title: () -> Title,
    @ViewBuilder leading: () -> Leading,
    @ViewBuilder trailing: () -> Trailing
  ) {
    self.title = title()
    self.leading = leading()
    self.trailing = trailing()
  }

  public var body: some View {
    HeaderLayout {
      HStack(spacing: 0) { leading }
        .frame(maxHeight: .infinity)
      ZStack { title }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
      HStack(spacing: 0) { trailing }
        .frame(maxHeight: .infinity)
    }
    .padding(.horizontal, 8)
    .frame(height: 44)
    .frame(maxWidth: .infinity)
    .background(background.ignoresSafeArea(.container, edges: .top))
    .overlay(alignment: .bottom) {
      EsuissColor.border
        .frame(height: 1)
        .opacity(progress ?? 0)
    }
    .accessibilityElement(children: .contain)
    .accessibilityAddTraits(.isHeader)
  }
}

extension Header where Leading == EmptyView {
  public init(@ViewBuilder title: () -> Title, @ViewBuilder trailing: () -> Trailing) {
    self.init(title: title, leading: { EmptyView() }, trailing: trailing)
  }
}

extension Header where Trailing == EmptyView {
  public init(@ViewBuilder title: () -> Title, @ViewBuilder leading: () -> Leading) {
    self.init(title: title, leading: leading, trailing: { EmptyView() })
  }
}

extension Header where Leading == EmptyView, Trailing == EmptyView {
  public init(@ViewBuilder title: () -> Title) {
    self.init(title: title, leading: { EmptyView() }, trailing: { EmptyView() })
  }
}

struct HeaderLayout: Layout {
  var spacing: CGFloat = 8
  var minSide: CGFloat = 44

  func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
    let ideal = subviews.reduce(0) { $0 + $1.sizeThatFits(.unspecified).width }
    return CGSize(width: proposal.width ?? ideal, height: proposal.height ?? 44)
  }

  func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
    guard subviews.count == 3 else { return }
    let leading = subviews[0]
    let title = subviews[1]
    let trailing = subviews[2]
    let trailingWidth = trailing.sizeThatFits(.unspecified).width
    let leadingIdeal = leading.sizeThatFits(.unspecified).width
    let leadingMinimum = leading.sizeThatFits(ProposedViewSize(width: 0, height: nil)).width
    let reserve = max(minSide, trailingWidth, leadingMinimum) + spacing
    let titleWidth = max(0, min(title.sizeThatFits(.unspecified).width, bounds.width - reserve * 2))
    let side = max(0, (bounds.width - titleWidth) / 2 - spacing)
    let leadingProposal = ProposedViewSize(width: min(side, leadingIdeal), height: nil)
    let leadingWidth = leading.sizeThatFits(leadingProposal).width
    let titleInset = max(leadingWidth, trailingWidth) + spacing
    leading.place(
      at: CGPoint(x: bounds.minX, y: bounds.minY),
      anchor: .topLeading,
      proposal: ProposedViewSize(width: leadingProposal.width, height: bounds.height)
    )
    title.place(
      at: CGPoint(x: bounds.minX + titleInset, y: bounds.minY),
      anchor: .topLeading,
      proposal: ProposedViewSize(width: max(0, bounds.width - titleInset * 2), height: bounds.height)
    )
    trailing.place(
      at: CGPoint(x: bounds.maxX - trailingWidth, y: bounds.minY),
      anchor: .topLeading,
      proposal: ProposedViewSize(width: trailingWidth, height: bounds.height)
    )
  }
}

public struct HeaderTitle: View {
  let title: Text
  let subtitle: Text?

  @Environment(\.headerProgress) private var progress

  public init(_ title: LocalizedStringKey, subtitle: LocalizedStringKey? = nil) {
    self.title = Text(title)
    self.subtitle = subtitle.map { Text($0) }
  }

  public init(verbatim title: String, subtitle: String? = nil) {
    self.title = Text(verbatim: title)
    self.subtitle = subtitle.map { Text(verbatim: $0) }
  }

  public var body: some View {
    VStack(spacing: 0) {
      title
        .scaledFont(.base, weight: .semibold)
        .foregroundStyle(EsuissColor.foreground)
      if let subtitle {
        subtitle
          .scaledFont(.xs)
          .foregroundStyle(EsuissColor.mutedForeground)
      }
    }
    .lineLimit(1)
    .multilineTextAlignment(.center)
    .opacity(progress ?? 1)
    .accessibilityElement(children: .combine)
    .accessibilityHidden((progress ?? 1) < 0.5)
  }
}

public struct HeaderLargeTitle: View {
  let title: Text
  let subtitle: Text?

  @Environment(\.headerReport) private var report

  public init(_ title: LocalizedStringKey, subtitle: LocalizedStringKey? = nil) {
    self.title = Text(title)
    self.subtitle = subtitle.map { Text($0) }
  }

  public init(verbatim title: String, subtitle: String? = nil) {
    self.title = Text(verbatim: title)
    self.subtitle = subtitle.map { Text(verbatim: $0) }
  }

  public var body: some View {
    VStack(alignment: .leading, spacing: 2) {
      title
        .scaledFont(.xl3, weight: .bold)
        .foregroundStyle(EsuissColor.foreground)
        .accessibilityAddTraits(.isHeader)
      if let subtitle {
        subtitle
          .scaledFont(.sm)
          .foregroundStyle(EsuissColor.mutedForeground)
      }
    }
    .lineLimit(2)
    .padding(.horizontal, 16)
    .padding(.top, 4)
    .padding(.bottom, 8)
    .frame(maxWidth: .infinity, alignment: .leading)
    .onGeometryChange(for: CGFloat.self) { proxy in
      HeaderMath.progress(minY: proxy.frame(in: .scrollView).minY, height: proxy.size.height)
    } action: { value in
      report?(value)
    }
  }
}

public struct HeaderBack: View {
  let label: Text?
  let action: (() -> Void)?

  @Environment(\.dismiss) private var dismiss
  @Environment(\.isEnabled) private var isEnabled

  public init(_ label: LocalizedStringKey? = nil, action: (() -> Void)? = nil) {
    self.label = label.map { Text($0) }
    self.action = action
  }

  public init(verbatim label: String, action: (() -> Void)? = nil) {
    self.label = Text(verbatim: label)
    self.action = action
  }

  public var body: some View {
    Button {
      if let action {
        action()
      } else {
        dismiss()
      }
    } label: {
      EmptyView()
    }
    .buttonStyle(PressReader { pressed in
      HStack(spacing: 2) {
        Ph.caretLeft.bold
          .frame(width: 20, height: 20)
          .flipsForRightToLeftLayoutDirection(true)
        if let label {
          label
            .scaledFont(.base)
            .lineLimit(1)
        }
      }
      .foregroundStyle(EsuissColor.primary)
      .opacity(isEnabled ? (pressed ? 0.8 : 1) : 0.5)
      .padding(.horizontal, 4)
      .frame(minWidth: 44, minHeight: 44, alignment: .leading)
      .contentShape(Rectangle())
    })
    .accessibilityLabel(label ?? Text("Back"))
  }
}

public struct HeaderActions<Content: View>: View {
  let content: Content

  public init(@ViewBuilder content: () -> Content) {
    self.content = content()
  }

  public var body: some View {
    HStack(spacing: 4) {
      content
    }
    .buttonStyle(.variant(.ghost, size: .icon))
  }
}

public struct HeaderScrollView<HeaderContent: View, Content: View>: View {
  let header: HeaderContent
  let content: Content

  @State private var progress: CGFloat?

  public init(@ViewBuilder content: () -> Content, @ViewBuilder header: () -> HeaderContent) {
    self.content = content()
    self.header = header()
  }

  public var body: some View {
    VStack(spacing: 0) {
      header
        .environment(\.headerProgress, progress)
        .zIndex(1)
      KeyboardScrollView {
        VStack(alignment: .leading, spacing: 0) {
          content
        }
      }
      .environment(\.headerReport) { value in
        if progress != value { progress = value }
      }
    }
  }
}
```

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

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

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

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

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

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

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

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

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

5. Add the files to your app target.

**Jetpack Compose**

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

```kotlin title="build.gradle.kts"
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
implementation("com.adamglin:phosphor-icon-android:1.0.0")
```

2. Copy the theme files every view uses:

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

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

3. Copy the source and every file it uses:

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

import androidx.compose.animation.core.animate
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
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.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.CaretLeft
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

private val HeaderBarHeight = 44.dp

private enum class HeaderSlot { Back, Start, End, Title, LargeTitle }

enum class HeaderActionsSide { Leading, Trailing }

@Stable
class HeaderState {
    internal var range by mutableFloatStateOf(0f)
    internal var offset by mutableFloatStateOf(0f)

    val collapsedFraction: Float
        get() = if (range <= 0f) 1f else (offset / range).coerceIn(0f, 1f)

    val nestedScrollConnection: NestedScrollConnection = object : NestedScrollConnection {
        override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
            if (available.y >= 0f || range <= 0f) return Offset.Zero
            val next = (offset - available.y).coerceAtMost(range)
            val used = next - offset
            offset = next
            return Offset(0f, -used)
        }

        override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
            if (available.y <= 0f || range <= 0f) return Offset.Zero
            val next = (offset - available.y).coerceAtLeast(0f)
            val used = offset - next
            offset = next
            return Offset(0f, used)
        }

        override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
            settle()
            return Velocity.Zero
        }
    }

    internal suspend fun settle() {
        if (range <= 0f || offset <= 0f || offset >= range) return
        val target = if (offset < range / 2f) 0f else range
        animate(offset, target, animationSpec = tween(250)) { value, _ -> offset = value }
    }
}

@Composable
fun rememberHeaderState(): HeaderState = remember { HeaderState() }

private val LocalHeaderState = staticCompositionLocalOf<HeaderState?> { null }

@Composable
fun Header(
    modifier: Modifier = Modifier,
    state: HeaderState = rememberHeaderState(),
    content: @Composable () -> Unit,
) {
    val colors = EsuissTheme.colors
    val collapsed = state.range > 0f && state.offset >= state.range
    val separator by animateFloatAsState(if (state.range <= 0f || collapsed) 1f else 0f, tween(150), label = "separator")
    CompositionLocalProvider(LocalHeaderState provides state) {
        Layout(
            content = content,
            modifier = modifier
                .clipToBounds()
                .drawWithContent {
                    drawContent()
                    val stroke = 1.dp.toPx()
                    drawRect(colors.border.copy(alpha = colors.border.alpha * separator), Offset(0f, size.height - stroke), Size(size.width, stroke))
                },
        ) { measurables, constraints ->
            val width = constraints.maxWidth
            val bar = HeaderBarHeight.roundToPx()
            val inset = 8.dp.roundToPx()
            val loose = Constraints(maxWidth = width, maxHeight = bar)
            val back = measurables.firstOrNull { it.layoutId == HeaderSlot.Back }?.measure(loose)
            val start = measurables.firstOrNull { it.layoutId == HeaderSlot.Start }?.measure(loose)
            val end = measurables.firstOrNull { it.layoutId == HeaderSlot.End }?.measure(loose)
            val leading = (back?.width ?: 0) + (start?.width ?: 0)
            val side = maxOf(leading, end?.width ?: 0) + inset
            val titleWidth = (width - side * 2).coerceAtLeast(0)
            val title = measurables.firstOrNull { it.layoutId == HeaderSlot.Title }
                ?.measure(Constraints(maxWidth = titleWidth, maxHeight = bar))
            val large = measurables.firstOrNull { it.layoutId == HeaderSlot.LargeTitle }
                ?.measure(Constraints(minWidth = width, maxWidth = width))
            val range = (large?.height ?: 0).toFloat()
            if (state.range != range) state.range = range
            val offset = state.offset.coerceIn(0f, range).toInt()
            val height = bar + (large?.height ?: 0) - offset
            layout(width, height) {
                var x = inset
                back?.let {
                    it.placeRelative(x, (bar - it.height) / 2)
                    x += it.width
                }
                start?.placeRelative(x, (bar - (start.height)) / 2)
                end?.let { it.placeRelative(width - inset - it.width, (bar - it.height) / 2) }
                title?.placeRelative((width - title.width) / 2, (bar - title.height) / 2)
                large?.placeRelative(0, bar - offset)
            }
        }
    }
}

@Composable
fun HeaderTitle(
    text: String,
    modifier: Modifier = Modifier,
    subtitle: String? = null,
) {
    val colors = EsuissTheme.colors
    val state = LocalHeaderState.current
    Column(
        modifier = modifier
            .layoutId(HeaderSlot.Title)
            .graphicsLayer {
                val fraction = state?.collapsedFraction ?: 1f
                alpha = if (state == null || state.range <= 0f) 1f else ((fraction - 0.6f) / 0.4f).coerceIn(0f, 1f)
            }
            .semantics { heading() },
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        BasicText(
            text,
            style = Typography.base.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold, textAlign = TextAlign.Center),
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
        )
        if (subtitle != null) {
            BasicText(
                subtitle,
                style = Typography.xs.copy(color = colors.mutedForeground, textAlign = TextAlign.Center),
                maxLines = 1,
                overflow = TextOverflow.Ellipsis,
            )
        }
    }
}

@Composable
fun HeaderLargeTitle(
    text: String,
    modifier: Modifier = Modifier,
    subtitle: String? = null,
) {
    val colors = EsuissTheme.colors
    val state = LocalHeaderState.current
    Column(
        modifier = modifier
            .layoutId(HeaderSlot.LargeTitle)
            .graphicsLayer {
                val fraction = state?.collapsedFraction ?: 0f
                alpha = (1f - fraction * 1.4f).coerceIn(0f, 1f)
            }
            .padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 8.dp),
        verticalArrangement = Arrangement.spacedBy(2.dp),
    ) {
        BasicText(
            text,
            modifier = Modifier.semantics { heading() },
            style = Typography.xl3.copy(color = colors.foreground, fontWeight = FontWeight.Bold),
            maxLines = 2,
            overflow = TextOverflow.Ellipsis,
        )
        if (subtitle != null) {
            BasicText(subtitle, style = Typography.sm.copy(color = colors.mutedForeground))
        }
    }
}

@Composable
fun HeaderBack(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    text: String? = "Back",
    contentDescription: String = text ?: "Back",
) {
    val colors = EsuissTheme.colors
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    Row(
        modifier = modifier
            .layoutId(HeaderSlot.Back)
            .height(HeaderBarHeight)
            .clickable(
                interactionSource = interaction,
                indication = null,
                role = Role.Button,
                onClickLabel = contentDescription,
                onClick = onClick,
            )
            .graphicsLayer { alpha = if (pressed) 0.4f else 1f }
            .padding(end = 8.dp),
        horizontalArrangement = Arrangement.spacedBy(2.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Icon(PhosphorIcons.Regular.CaretLeft, contentDescription = if (text == null) contentDescription else null, size = 24.dp, tint = colors.primary)
        if (text != null) {
            BasicText(text, style = Typography.base.copy(color = colors.primary), maxLines = 1)
        }
    }
}

@Composable
fun HeaderActions(
    modifier: Modifier = Modifier,
    side: HeaderActionsSide = HeaderActionsSide.Trailing,
    content: @Composable RowScope.() -> Unit,
) {
    Row(
        modifier = modifier.layoutId(if (side == HeaderActionsSide.Leading) HeaderSlot.Start else HeaderSlot.End),
        horizontalArrangement = Arrangement.spacedBy(4.dp),
        verticalAlignment = Alignment.CenterVertically,
        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 { Header, HeaderTitle, HeaderLargeTitle, HeaderBack, HeaderActions, HeaderScrollView } from "@/components/ui/header"
```

```tsx
<View className="-mx-4 -mt-4">
  <Header>
    <HeaderBack>Settings</HeaderBack>
    <HeaderTitle>Profile</HeaderTitle>
    <HeaderActions>
      <Button variant="plain" size="sm">
        <Text className="font-semibold">Edit</Text>
      </Button>
    </HeaderActions>
  </Header>
</View>
```

`HeaderBack` shows a caret and an optional text label such as the previous screen's name; wire `onPress` to your navigation. Group icon buttons in `HeaderActions`, and set `side="leading"` to place a group on the start side.

Both `HeaderTitle` and `HeaderLargeTitle` accept a `subtitle`. Plain strings are wrapped in `Text` and truncated to one line in the bar.

Without a large title, you can still pass the same `scroll` to `Header` and `HeaderScrollView` to show the hairline once content scrolls under the bar.

## Examples

### Large title

A large title that collapses into the bar on scroll.

![Preview](https://ui.suiss.app/previews/components-header--large-title.light.webp)

```tsx
import { MagnifyingGlassIcon, PlusIcon } from "phosphor-react-native"
import { View } from "react-native"
import { Button } from "@/components/ui/button"
import { Header, HeaderActions, HeaderBack, HeaderLargeTitle, HeaderScrollView, HeaderTitle, useHeaderScroll } from "@/components/ui/header"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

const messages = [
  "Lunch on Friday?",
  "The draft is ready for review",
  "Photos from the trip",
  "Invoice for September",
  "Your order has shipped",
  "Team sync moved to 3 PM",
  "Weekend plans",
  "Book club picks",
  "Flight confirmation",
  "New comment on your post",
  "Reminder: dentist at 10",
  "Quarterly report",
  "Welcome to the beta",
  "Parking update",
  "Recipe you asked for",
]

function MessageRows() {
  return (
    <View className="px-4">
      {messages.map((message, index) => (
        <View
          key={message}
          className={index > 0 ? "border-t border-border py-3" : "py-3"}
        >
          <Text numberOfLines={1}>{message}</Text>
        </View>
      ))}
    </View>
  )
}

function TrailingActions() {
  return (
    <HeaderActions>
      <Button variant="ghost" size="icon" accessibilityLabel="Search">
        <Icon as={MagnifyingGlassIcon} className="size-5" />
      </Button>
      <Button variant="ghost" size="icon" accessibilityLabel="New message">
        <Icon as={PlusIcon} className="size-5" />
      </Button>
    </HeaderActions>
  )
}

function LargeTitleDemo({ subtitle }: { subtitle?: string }) {
  const scroll = useHeaderScroll()
  return (
    <View className="-m-4 flex-1">
      <Header scroll={scroll}>
        <HeaderBack>Mailboxes</HeaderBack>
        <HeaderTitle>Inbox</HeaderTitle>
        <TrailingActions />
      </Header>
      <HeaderScrollView scroll={scroll}>
        <HeaderLargeTitle subtitle={subtitle}>Inbox</HeaderLargeTitle>
        <MessageRows />
      </HeaderScrollView>
    </View>
  )
}

export function HeaderLargeTitleExample() {
  return (
    <LargeTitleDemo />
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

let headerMessages = [
  "Lunch on Friday?",
  "The draft is ready for review",
  "Photos from the trip",
  "Invoice for September",
  "Your order has shipped",
  "Team sync moved to 3 PM",
  "Weekend plans",
  "Book club picks",
  "Flight confirmation",
  "New comment on your post",
  "Reminder: dentist at 10",
  "Quarterly report",
  "Welcome to the beta",
  "Parking update",
  "Recipe you asked for",
]

extension View {
  func headerStoryBleed() -> some View {
    padding(.horizontal, -16).padding(.top, -16)
  }
}

struct HeaderMessageRows: View {
  var body: some View {
    VStack(alignment: .leading, spacing: 0) {
      ForEach(Array(headerMessages.enumerated()), id: \.offset) { index, message in
        Text(LocalizedStringKey(message))
          .textStyle(.base)
          .foregroundStyle(EsuissColor.foreground)
          .lineLimit(1)
          .padding(.vertical, 12)
          .frame(maxWidth: .infinity, alignment: .leading)
          .overlay(alignment: .top) {
            if index > 0 {
              EsuissColor.border.frame(height: 1)
            }
          }
      }
    }
    .padding(.horizontal, 16)
  }
}

struct HeaderIconButton: View {
  let icon: Image
  let label: LocalizedStringKey

  var body: some View {
    Button {} label: {
      icon.frame(width: 20, height: 20)
    }
    .accessibilityLabel(Text(label))
  }
}

struct HeaderTrailingActions: View {
  var body: some View {
    HeaderActions {
      HeaderIconButton(icon: Ph.magnifyingGlass.regular, label: "Search")
      HeaderIconButton(icon: Ph.plus.regular, label: "New message")
    }
  }
}

struct HeaderLargeTitleDemo: View {
  var subtitle: LocalizedStringKey?

  var body: some View {
    HeaderScrollView {
      HeaderLargeTitle("Inbox", subtitle: subtitle)
      HeaderMessageRows()
    } header: {
      Header {
        HeaderTitle("Inbox")
      } leading: {
        HeaderBack("Mailboxes")
      } trailing: {
        HeaderTrailingActions()
      }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.layout
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.MagnifyingGlass
import com.adamglin.phosphoricons.regular.Plus
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonSize
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Header
import com.esuiss.ui.components.HeaderActions
import com.esuiss.ui.components.HeaderBack
import com.esuiss.ui.components.HeaderLargeTitle
import com.esuiss.ui.components.HeaderTitle
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.Screen
import com.esuiss.ui.components.Separator
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.rememberHeaderState

@Composable
fun HeaderLargeTitleExample() {
    HeaderLargeTitleDemo(subtitle = null)
}

val HeaderMessages = listOf(
    "Lunch on Friday?",
    "The draft is ready for review",
    "Photos from the trip",
    "Invoice for September",
    "Your order has shipped",
    "Team sync moved to 3 PM",
    "Weekend plans",
    "Book club picks",
    "Flight confirmation",
    "New comment on your post",
    "Reminder: dentist at 10",
    "Quarterly report",
    "Welcome to the beta",
    "Parking update",
    "Recipe you asked for",
)

fun Modifier.headerBleed(): Modifier = layout { measurable, constraints ->
    val inset = 16.dp.roundToPx()
    val width = constraints.maxWidth + inset * 2
    val placeable = measurable.measure(constraints.copy(minWidth = width, maxWidth = width))
    layout(constraints.maxWidth, (placeable.height - inset).coerceAtLeast(0)) {
        placeable.place(-inset, -inset)
    }
}

@Composable
fun HeaderMessageRows() {
    Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
        HeaderMessages.forEachIndexed { index, message ->
            if (index > 0) Separator()
            Text(
                message,
                modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
                maxLines = 1,
                overflow = TextOverflow.Ellipsis,
            )
        }
    }
}

@Composable
fun HeaderTrailingActions() {
    HeaderActions {
        Button(onClick = {}, variant = ButtonVariant.Ghost, size = ButtonSize.Icon) {
            Icon(PhosphorIcons.Regular.MagnifyingGlass, contentDescription = "Search")
        }
        Button(onClick = {}, variant = ButtonVariant.Ghost, size = ButtonSize.Icon) {
            Icon(PhosphorIcons.Regular.Plus, contentDescription = "New message")
        }
    }
}

@Composable
fun HeaderLargeTitleDemo(subtitle: String?) {
    val state = rememberHeaderState()
    Screen {
        Column(Modifier.fillMaxSize().nestedScroll(state.nestedScrollConnection)) {
            Header(state = state) {
                HeaderBack(onClick = {}, text = "Mailboxes")
                HeaderTitle("Inbox")
                HeaderLargeTitle("Inbox", subtitle = subtitle)
                HeaderTrailingActions()
            }
            Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState())) {
                HeaderMessageRows()
            }
        }
    }
}
```

### Large title with subtitle

A large title with a subtitle.

![Preview](https://ui.suiss.app/previews/components-header--large-title-with-subtitle.light.webp)

```tsx
import { MagnifyingGlassIcon, PlusIcon } from "phosphor-react-native"
import { View } from "react-native"
import { Button } from "@/components/ui/button"
import { Header, HeaderActions, HeaderBack, HeaderLargeTitle, HeaderScrollView, HeaderTitle, useHeaderScroll } from "@/components/ui/header"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

const messages = [
  "Lunch on Friday?",
  "The draft is ready for review",
  "Photos from the trip",
  "Invoice for September",
  "Your order has shipped",
  "Team sync moved to 3 PM",
  "Weekend plans",
  "Book club picks",
  "Flight confirmation",
  "New comment on your post",
  "Reminder: dentist at 10",
  "Quarterly report",
  "Welcome to the beta",
  "Parking update",
  "Recipe you asked for",
]

function MessageRows() {
  return (
    <View className="px-4">
      {messages.map((message, index) => (
        <View
          key={message}
          className={index > 0 ? "border-t border-border py-3" : "py-3"}
        >
          <Text numberOfLines={1}>{message}</Text>
        </View>
      ))}
    </View>
  )
}

function TrailingActions() {
  return (
    <HeaderActions>
      <Button variant="ghost" size="icon" accessibilityLabel="Search">
        <Icon as={MagnifyingGlassIcon} className="size-5" />
      </Button>
      <Button variant="ghost" size="icon" accessibilityLabel="New message">
        <Icon as={PlusIcon} className="size-5" />
      </Button>
    </HeaderActions>
  )
}

function LargeTitleDemo({ subtitle }: { subtitle?: string }) {
  const scroll = useHeaderScroll()
  return (
    <View className="-m-4 flex-1">
      <Header scroll={scroll}>
        <HeaderBack>Mailboxes</HeaderBack>
        <HeaderTitle>Inbox</HeaderTitle>
        <TrailingActions />
      </Header>
      <HeaderScrollView scroll={scroll}>
        <HeaderLargeTitle subtitle={subtitle}>Inbox</HeaderLargeTitle>
        <MessageRows />
      </HeaderScrollView>
    </View>
  )
}

export function HeaderLargeTitleWithSubtitleExample() {
  return (
    <LargeTitleDemo subtitle="3 unread" />
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct HeaderLargeTitleWithSubtitleExample: View {
  var body: some View {
    HeaderLargeTitleDemo(subtitle: "3 unread")
  }
}

let headerMessages = [
  "Lunch on Friday?",
  "The draft is ready for review",
  "Photos from the trip",
  "Invoice for September",
  "Your order has shipped",
  "Team sync moved to 3 PM",
  "Weekend plans",
  "Book club picks",
  "Flight confirmation",
  "New comment on your post",
  "Reminder: dentist at 10",
  "Quarterly report",
  "Welcome to the beta",
  "Parking update",
  "Recipe you asked for",
]

extension View {
  func headerStoryBleed() -> some View {
    padding(.horizontal, -16).padding(.top, -16)
  }
}

struct HeaderMessageRows: View {
  var body: some View {
    VStack(alignment: .leading, spacing: 0) {
      ForEach(Array(headerMessages.enumerated()), id: \.offset) { index, message in
        Text(LocalizedStringKey(message))
          .textStyle(.base)
          .foregroundStyle(EsuissColor.foreground)
          .lineLimit(1)
          .padding(.vertical, 12)
          .frame(maxWidth: .infinity, alignment: .leading)
          .overlay(alignment: .top) {
            if index > 0 {
              EsuissColor.border.frame(height: 1)
            }
          }
      }
    }
    .padding(.horizontal, 16)
  }
}

struct HeaderIconButton: View {
  let icon: Image
  let label: LocalizedStringKey

  var body: some View {
    Button {} label: {
      icon.frame(width: 20, height: 20)
    }
    .accessibilityLabel(Text(label))
  }
}

struct HeaderTrailingActions: View {
  var body: some View {
    HeaderActions {
      HeaderIconButton(icon: Ph.magnifyingGlass.regular, label: "Search")
      HeaderIconButton(icon: Ph.plus.regular, label: "New message")
    }
  }
}

struct HeaderLargeTitleDemo: View {
  var subtitle: LocalizedStringKey?

  var body: some View {
    HeaderScrollView {
      HeaderLargeTitle("Inbox", subtitle: subtitle)
      HeaderMessageRows()
    } header: {
      Header {
        HeaderTitle("Inbox")
      } leading: {
        HeaderBack("Mailboxes")
      } trailing: {
        HeaderTrailingActions()
      }
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.layout
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.MagnifyingGlass
import com.adamglin.phosphoricons.regular.Plus
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonSize
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Header
import com.esuiss.ui.components.HeaderActions
import com.esuiss.ui.components.HeaderBack
import com.esuiss.ui.components.HeaderLargeTitle
import com.esuiss.ui.components.HeaderTitle
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.Screen
import com.esuiss.ui.components.Separator
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.rememberHeaderState

@Composable
fun HeaderLargeTitleWithSubtitleExample() {
    HeaderLargeTitleDemo(subtitle = "3 unread")
}

val HeaderMessages = listOf(
    "Lunch on Friday?",
    "The draft is ready for review",
    "Photos from the trip",
    "Invoice for September",
    "Your order has shipped",
    "Team sync moved to 3 PM",
    "Weekend plans",
    "Book club picks",
    "Flight confirmation",
    "New comment on your post",
    "Reminder: dentist at 10",
    "Quarterly report",
    "Welcome to the beta",
    "Parking update",
    "Recipe you asked for",
)

fun Modifier.headerBleed(): Modifier = layout { measurable, constraints ->
    val inset = 16.dp.roundToPx()
    val width = constraints.maxWidth + inset * 2
    val placeable = measurable.measure(constraints.copy(minWidth = width, maxWidth = width))
    layout(constraints.maxWidth, (placeable.height - inset).coerceAtLeast(0)) {
        placeable.place(-inset, -inset)
    }
}

@Composable
fun HeaderMessageRows() {
    Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
        HeaderMessages.forEachIndexed { index, message ->
            if (index > 0) Separator()
            Text(
                message,
                modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
                maxLines = 1,
                overflow = TextOverflow.Ellipsis,
            )
        }
    }
}

@Composable
fun HeaderTrailingActions() {
    HeaderActions {
        Button(onClick = {}, variant = ButtonVariant.Ghost, size = ButtonSize.Icon) {
            Icon(PhosphorIcons.Regular.MagnifyingGlass, contentDescription = "Search")
        }
        Button(onClick = {}, variant = ButtonVariant.Ghost, size = ButtonSize.Icon) {
            Icon(PhosphorIcons.Regular.Plus, contentDescription = "New message")
        }
    }
}

@Composable
fun HeaderLargeTitleDemo(subtitle: String?) {
    val state = rememberHeaderState()
    Screen {
        Column(Modifier.fillMaxSize().nestedScroll(state.nestedScrollConnection)) {
            Header(state = state) {
                HeaderBack(onClick = {}, text = "Mailboxes")
                HeaderTitle("Inbox")
                HeaderLargeTitle("Inbox", subtitle = subtitle)
                HeaderTrailingActions()
            }
            Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState())) {
                HeaderMessageRows()
            }
        }
    }
}
```

### Compact with scroll

A compact title whose hairline appears on scroll.

![Preview](https://ui.suiss.app/previews/components-header--compact-with-scroll.light.webp)

```tsx
import { DotsThreeIcon } from "phosphor-react-native"
import { View } from "react-native"
import { Button } from "@/components/ui/button"
import { Header, HeaderActions, HeaderBack, HeaderScrollView, HeaderTitle, useHeaderScroll } from "@/components/ui/header"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

const messages = [
  "Lunch on Friday?",
  "The draft is ready for review",
  "Photos from the trip",
  "Invoice for September",
  "Your order has shipped",
  "Team sync moved to 3 PM",
  "Weekend plans",
  "Book club picks",
  "Flight confirmation",
  "New comment on your post",
  "Reminder: dentist at 10",
  "Quarterly report",
  "Welcome to the beta",
  "Parking update",
  "Recipe you asked for",
]

function MessageRows() {
  return (
    <View className="px-4">
      {messages.map((message, index) => (
        <View
          key={message}
          className={index > 0 ? "border-t border-border py-3" : "py-3"}
        >
          <Text numberOfLines={1}>{message}</Text>
        </View>
      ))}
    </View>
  )
}

function CompactScrollDemo() {
  const scroll = useHeaderScroll()
  return (
    <View className="-m-4 flex-1">
      <Header scroll={scroll}>
        <HeaderBack />
        <HeaderTitle subtitle="15 messages">Inbox</HeaderTitle>
        <HeaderActions>
          <Button variant="ghost" size="icon" accessibilityLabel="More">
            <Icon as={DotsThreeIcon} weight="bold" className="size-5" />
          </Button>
        </HeaderActions>
      </Header>
      <HeaderScrollView scroll={scroll}>
        <MessageRows />
      </HeaderScrollView>
    </View>
  )
}

export function HeaderCompactWithScrollExample() {
  return (
    <CompactScrollDemo />
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct HeaderCompactWithScrollExample: View {
  var body: some View {
    HeaderScrollView {
      HeaderMessageRows()
    } header: {
      Header {
        HeaderTitle("Inbox", subtitle: "15 messages")
      } leading: {
        HeaderBack()
      } trailing: {
        HeaderActions {
          HeaderIconButton(icon: Ph.dotsThree.bold, label: "More")
        }
      }
    }
  }
}

let headerMessages = [
  "Lunch on Friday?",
  "The draft is ready for review",
  "Photos from the trip",
  "Invoice for September",
  "Your order has shipped",
  "Team sync moved to 3 PM",
  "Weekend plans",
  "Book club picks",
  "Flight confirmation",
  "New comment on your post",
  "Reminder: dentist at 10",
  "Quarterly report",
  "Welcome to the beta",
  "Parking update",
  "Recipe you asked for",
]

extension View {
  func headerStoryBleed() -> some View {
    padding(.horizontal, -16).padding(.top, -16)
  }
}

struct HeaderMessageRows: View {
  var body: some View {
    VStack(alignment: .leading, spacing: 0) {
      ForEach(Array(headerMessages.enumerated()), id: \.offset) { index, message in
        Text(LocalizedStringKey(message))
          .textStyle(.base)
          .foregroundStyle(EsuissColor.foreground)
          .lineLimit(1)
          .padding(.vertical, 12)
          .frame(maxWidth: .infinity, alignment: .leading)
          .overlay(alignment: .top) {
            if index > 0 {
              EsuissColor.border.frame(height: 1)
            }
          }
      }
    }
    .padding(.horizontal, 16)
  }
}

struct HeaderIconButton: View {
  let icon: Image
  let label: LocalizedStringKey

  var body: some View {
    Button {} label: {
      icon.frame(width: 20, height: 20)
    }
    .accessibilityLabel(Text(label))
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layout
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Bold
import com.adamglin.phosphoricons.bold.DotsThree
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonSize
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Header
import com.esuiss.ui.components.HeaderActions
import com.esuiss.ui.components.HeaderBack
import com.esuiss.ui.components.HeaderTitle
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.Screen
import com.esuiss.ui.components.Separator
import com.esuiss.ui.components.Text

val HeaderMessages = listOf(
    "Lunch on Friday?",
    "The draft is ready for review",
    "Photos from the trip",
    "Invoice for September",
    "Your order has shipped",
    "Team sync moved to 3 PM",
    "Weekend plans",
    "Book club picks",
    "Flight confirmation",
    "New comment on your post",
    "Reminder: dentist at 10",
    "Quarterly report",
    "Welcome to the beta",
    "Parking update",
    "Recipe you asked for",
)

fun Modifier.headerBleed(): Modifier = layout { measurable, constraints ->
    val inset = 16.dp.roundToPx()
    val width = constraints.maxWidth + inset * 2
    val placeable = measurable.measure(constraints.copy(minWidth = width, maxWidth = width))
    layout(constraints.maxWidth, (placeable.height - inset).coerceAtLeast(0)) {
        placeable.place(-inset, -inset)
    }
}

@Composable
fun HeaderMessageRows() {
    Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) {
        HeaderMessages.forEachIndexed { index, message ->
            if (index > 0) Separator()
            Text(
                message,
                modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
                maxLines = 1,
                overflow = TextOverflow.Ellipsis,
            )
        }
    }
}

@Composable
fun HeaderCompactScroll() {
    Screen {
        Column(Modifier.fillMaxSize()) {
            Header {
                HeaderBack(onClick = {}, text = null)
                HeaderTitle("Inbox", subtitle = "15 messages")
                HeaderActions {
                    Button(onClick = {}, variant = ButtonVariant.Ghost, size = ButtonSize.Icon) {
                        Icon(PhosphorIcons.Bold.DotsThree, contentDescription = "More")
                    }
                }
            }
            Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState())) {
                HeaderMessageRows()
            }
        }
    }
}
```

### Subtitle

A two-line title with a subtitle.

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

```tsx
import { MagnifyingGlassIcon, PlusIcon } from "phosphor-react-native"
import { View } from "react-native"
import { Button } from "@/components/ui/button"
import { Header, HeaderActions, HeaderBack, HeaderTitle } from "@/components/ui/header"
import { Icon } from "@/components/ui/icon"

function TrailingActions() {
  return (
    <HeaderActions>
      <Button variant="ghost" size="icon" accessibilityLabel="Search">
        <Icon as={MagnifyingGlassIcon} className="size-5" />
      </Button>
      <Button variant="ghost" size="icon" accessibilityLabel="New message">
        <Icon as={PlusIcon} className="size-5" />
      </Button>
    </HeaderActions>
  )
}

export function HeaderSubtitleExample() {
  return (
    <View className="-mx-4 -mt-4">
      <Header>
        <HeaderBack />
        <HeaderTitle subtitle="Last seen today">Alex Morgan</HeaderTitle>
        <TrailingActions />
      </Header>
    </View>
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct HeaderSubtitleExample: View {
  var body: some View {
    Header {
      HeaderTitle("Alex Morgan", subtitle: "Last seen today")
    } leading: {
      HeaderBack()
    } trailing: {
      HeaderTrailingActions()
    }
    .headerStoryBleed()
  }
}

extension View {
  func headerStoryBleed() -> some View {
    padding(.horizontal, -16).padding(.top, -16)
  }
}

struct HeaderIconButton: View {
  let icon: Image
  let label: LocalizedStringKey

  var body: some View {
    Button {} label: {
      icon.frame(width: 20, height: 20)
    }
    .accessibilityLabel(Text(label))
  }
}

struct HeaderTrailingActions: View {
  var body: some View {
    HeaderActions {
      HeaderIconButton(icon: Ph.magnifyingGlass.regular, label: "Search")
      HeaderIconButton(icon: Ph.plus.regular, label: "New message")
    }
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layout
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.MagnifyingGlass
import com.adamglin.phosphoricons.regular.Plus
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonSize
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Header
import com.esuiss.ui.components.HeaderActions
import com.esuiss.ui.components.HeaderBack
import com.esuiss.ui.components.HeaderTitle
import com.esuiss.ui.components.Icon

fun Modifier.headerBleed(): Modifier = layout { measurable, constraints ->
    val inset = 16.dp.roundToPx()
    val width = constraints.maxWidth + inset * 2
    val placeable = measurable.measure(constraints.copy(minWidth = width, maxWidth = width))
    layout(constraints.maxWidth, (placeable.height - inset).coerceAtLeast(0)) {
        placeable.place(-inset, -inset)
    }
}

@Composable
fun HeaderTrailingActions() {
    HeaderActions {
        Button(onClick = {}, variant = ButtonVariant.Ghost, size = ButtonSize.Icon) {
            Icon(PhosphorIcons.Regular.MagnifyingGlass, contentDescription = "Search")
        }
        Button(onClick = {}, variant = ButtonVariant.Ghost, size = ButtonSize.Icon) {
            Icon(PhosphorIcons.Regular.Plus, contentDescription = "New message")
        }
    }
}

@Composable
fun HeaderSubtitle() {
    Column(Modifier.headerBleed()) {
        Header {
            HeaderBack(onClick = {}, text = null)
            HeaderTitle("Alex Morgan", subtitle = "Last seen today")
            HeaderTrailingActions()
        }
    }
}
```

### Leading actions

Actions on both sides of the title.

![Preview](https://ui.suiss.app/previews/components-header--leading-actions.light.webp)

```tsx
import { ListIcon } from "phosphor-react-native"
import { View } from "react-native"
import { Button } from "@/components/ui/button"
import { Header, HeaderActions, HeaderTitle } from "@/components/ui/header"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

export function HeaderLeadingActionsExample() {
  return (
    <View className="-mx-4 -mt-4">
      <Header>
        <HeaderActions side="leading">
          <Button variant="ghost" size="icon" accessibilityLabel="Menu">
            <Icon as={ListIcon} className="size-5" />
          </Button>
        </HeaderActions>
        <HeaderTitle>Library</HeaderTitle>
        <HeaderActions>
          <Button variant="plain" size="sm">
            <Text className="font-semibold">Done</Text>
          </Button>
        </HeaderActions>
      </Header>
    </View>
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct HeaderLeadingActionsExample: View {
  var body: some View {
    Header {
      HeaderTitle("Library")
    } leading: {
      HeaderActions {
        HeaderIconButton(icon: Ph.list.regular, label: "Menu")
      }
    } trailing: {
      HeaderActions {
        Button {} label: {
          Text("Done").fontWeight(.semibold)
        }
        .buttonStyle(.variant(.plain, size: .sm))
      }
    }
    .headerStoryBleed()
  }
}

extension View {
  func headerStoryBleed() -> some View {
    padding(.horizontal, -16).padding(.top, -16)
  }
}

struct HeaderIconButton: View {
  let icon: Image
  let label: LocalizedStringKey

  var body: some View {
    Button {} label: {
      icon.frame(width: 20, height: 20)
    }
    .accessibilityLabel(Text(label))
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layout
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.List
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonSize
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Header
import com.esuiss.ui.components.HeaderActions
import com.esuiss.ui.components.HeaderActionsSide
import com.esuiss.ui.components.HeaderTitle
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.Text

fun Modifier.headerBleed(): Modifier = layout { measurable, constraints ->
    val inset = 16.dp.roundToPx()
    val width = constraints.maxWidth + inset * 2
    val placeable = measurable.measure(constraints.copy(minWidth = width, maxWidth = width))
    layout(constraints.maxWidth, (placeable.height - inset).coerceAtLeast(0)) {
        placeable.place(-inset, -inset)
    }
}

@Composable
fun HeaderPlainAction(text: String) {
    Button(onClick = {}, variant = ButtonVariant.Plain, size = ButtonSize.Sm) {
        Text(text, style = TextStyle(fontWeight = FontWeight.SemiBold))
    }
}

@Composable
fun HeaderLeadingActions() {
    Column(Modifier.headerBleed()) {
        Header {
            HeaderActions(side = HeaderActionsSide.Leading) {
                Button(onClick = {}, variant = ButtonVariant.Ghost, size = ButtonSize.Icon) {
                    Icon(PhosphorIcons.Regular.List, contentDescription = "Menu")
                }
            }
            HeaderTitle("Library")
            HeaderActions { HeaderPlainAction("Done") }
        }
    }
}
```

### Back only

A long back label that truncates.

![Preview](https://ui.suiss.app/previews/components-header--back-only.light.webp)

```tsx
import { View } from "react-native"
import { Header, HeaderBack, HeaderTitle } from "@/components/ui/header"

export function HeaderBackOnlyExample() {
  return (
    <View className="-mx-4 -mt-4">
      <Header>
        <HeaderBack>A very long previous screen title</HeaderBack>
        <HeaderTitle>Details</HeaderTitle>
      </Header>
    </View>
  )
}
```

```swift
import EsuissUI
import SwiftUI

struct HeaderBackOnlyExample: View {
  var body: some View {
    Header {
      HeaderTitle("Details")
    } leading: {
      HeaderBack("A very long previous screen title")
    }
    .headerStoryBleed()
  }
}
```

```kotlin
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layout
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.Header
import com.esuiss.ui.components.HeaderBack
import com.esuiss.ui.components.HeaderTitle

fun Modifier.headerBleed(): Modifier = layout { measurable, constraints ->
    val inset = 16.dp.roundToPx()
    val width = constraints.maxWidth + inset * 2
    val placeable = measurable.measure(constraints.copy(minWidth = width, maxWidth = width))
    layout(constraints.maxWidth, (placeable.height - inset).coerceAtLeast(0)) {
        placeable.place(-inset, -inset)
    }
}

@Composable
fun HeaderBackOnly() {
    Column(Modifier.headerBleed()) {
        Header {
            HeaderBack(onClick = {}, text = "A very long previous screen title")
            HeaderTitle("Details")
        }
    }
}
```

## API Reference

### Header

Bar that lays out the back button, title and actions.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `scroll` | `HeaderScroll` | - | Scroll state from `useHeaderScroll()`. |

Also accepts the props of `ViewProps`.

### HeaderTitle

Centered title with optional subtitle.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `subtitle` | `React.ReactNode` | - | Second line under the title. |

Also accepts the props of `ViewProps`.

### HeaderLargeTitle

Large title at the top of the scroll content.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `subtitle` | `React.ReactNode` | - | Line under the large title. |

Also accepts the props of `ViewProps`.

### HeaderBack

Back button with a caret and optional label.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `React.ReactNode` | - |  |

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

### HeaderActions

Row of actions for either side.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `side` | `"leading" \| "trailing"` | `"trailing"` | `leading` or `trailing`. Defaults to `trailing`. |

Also accepts the props of `ViewProps`.

### HeaderScrollView

Scroll view that drives the header animations.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `scroll` | `HeaderScroll` | Required | Scroll state from `useHeaderScroll()`. |

Also accepts the props of `ScrollViewProps`.

## Accessibility

- `HeaderTitle` and a string `HeaderLargeTitle` have the `heading` role.
- `HeaderBack` has the `button` role and is labelled with its text, or "Back" when it has none.
- Give icon-only actions an `accessibilityLabel`.

## Notes

- The back caret rotates and the large title scales from the correct edge in right-to-left layouts.
- Uses `text-primary` and `bg-border` tokens, with `opacity-80` while pressed; light and dark follow the system appearance.

