# Tip

One-time hints shown as an inline card or a popover pointing at a control, remembered once dismissed.

Tip is an open-source mobile interaction 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/interactions/tip

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

```tsx
import { NotePencilIcon, PushPinIcon, ShareNetworkIcon } from "phosphor-react-native"
import * as React from "react"
import { Pressable, View } from "react-native"
import { resetTips, Tip, TipAnchor, TipCard, TipClose, TipContent, TipDescription, TipGroup, TipIcon, TipPopover, TipTitle } from "@/components/interactions/tip"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

const notes = [
  { id: 1, title: "Groceries", preview: "Milk, eggs, bread, coffee" },
  { id: 2, title: "Trip ideas", preview: "Lisbon, Porto, the coast road" },
  { id: 3, title: "Book list", preview: "Three novels for the summer" },
  { id: 4, title: "Meeting notes", preview: "Ship the beta on Friday" },
]

const TIP_IDS = ["notes-pin", "notes-compose"]

function NotesPage() {
  const [log, setLog] = React.useState("")
  return (
    <TipGroup>
      <View className="w-full gap-4">
        <View className="flex-row items-center justify-between">
          <Text variant="h1">Notes</Text>
          <View className="flex-row items-center">
            <Button
              variant="plain"
              size="icon-lg"
              accessibilityLabel="Share folder"
              onPress={() => setLog("Shared the folder")}
            >
              <Icon as={ShareNetworkIcon} className="size-5.5" />
            </Button>
            <Tip id="notes-compose" delay={600}>
              <TipAnchor>
                <Button
                  variant="plain"
                  size="icon-lg"
                  accessibilityLabel="New note"
                  onPress={() => setLog("Started a new note")}
                >
                  <Icon as={NotePencilIcon} className="size-5.5" />
                </Button>
              </TipAnchor>
              <TipPopover side="bottom" align="end">
                <TipIcon>
                  <Icon as={NotePencilIcon} weight="duotone" />
                </TipIcon>
                <TipContent>
                  <TipTitle>Start a note fast</TipTitle>
                  <TipDescription>
                    Tap here any time to begin a new note.
                  </TipDescription>
                </TipContent>
                <TipClose />
              </TipPopover>
            </Tip>
          </View>
        </View>
        <Tip id="notes-pin">
          <TipCard>
            <TipIcon>
              <Icon as={PushPinIcon} weight="duotone" />
            </TipIcon>
            <TipContent>
              <TipTitle>Pin your favorites</TipTitle>
              <TipDescription>
                Swipe right on a note to keep it at the top of the list.
              </TipDescription>
            </TipContent>
            <TipClose />
          </TipCard>
        </Tip>
        <View className="overflow-hidden rounded-2xl border border-separator">
          {notes.map((note, index) => (
            <Pressable
              key={note.id}
              onPress={() => setLog(`Opened ${note.title}`)}
              className={
                index > 0
                  ? "border-t border-separator px-4 py-3 active:bg-item-pressed"
                  : "px-4 py-3 active:bg-item-pressed"
              }
            >
              <Text numberOfLines={1} className="font-semibold">
                {note.title}
              </Text>
              <Text numberOfLines={1} className="text-sm text-label-secondary">
                {note.preview}
              </Text>
            </Pressable>
          ))}
        </View>
        <Text role="status" className="min-h-5 text-sm text-label-secondary">
          {log}
        </Text>
      </View>
    </TipGroup>
  )
}

function TipsExample({ fresh = false }: { fresh?: boolean }) {
  const [key, setKey] = React.useState(0)
  const [ready, setReady] = React.useState(!fresh)

  React.useEffect(() => {
    if (!fresh) return
    resetTips(TIP_IDS).then(() => setReady(true))
  }, [fresh])

  return (
    <View className="gap-6">
      {ready ? <NotesPage key={key} /> : null}
      <Button
        variant="secondary"
        size="sm"
        className="self-center"
        onPress={() => {
          resetTips(TIP_IDS).then(() => setKey((value) => value + 1))
        }}
      >
        <Text>Show tips again</Text>
      </Button>
    </View>
  )
}

export function TipExample() {
  return (
    <TipsExample fresh />
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

let tipIDs = ["notes-pin", "notes-compose"]

struct TipNote: Identifiable {
  let id: Int
  let title: String
  let preview: String
}

let tipNotes = [
  TipNote(id: 1, title: "Groceries", preview: "Milk, eggs, bread, coffee"),
  TipNote(id: 2, title: "Trip ideas", preview: "Lisbon, Porto, the coast road"),
  TipNote(id: 3, title: "Book list", preview: "Three novels for the summer"),
  TipNote(id: 4, title: "Meeting notes", preview: "Ship the beta on Friday"),
]

struct TipPinCard: View {
  let id: String

  var body: some View {
    Tip(id: id) {
      TipCard {
        TipIcon { Ph.pushPin.duotone }
        TipContent {
          TipTitle("Pin your favorites")
          TipDescription("Swipe right on a note to keep it at the top of the list.")
        }
        TipClose()
      }
    }
  }
}

struct TipNotesPage: View {
  @State private var log = ""

  var body: some View {
    let shape = RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous)
    TipGroup {
      VStack(alignment: .leading, spacing: 16) {
        HStack {
          Text("Notes")
            .textVariant(.h1)
          Spacer()
          Button {
            log = "Shared the folder"
          } label: {
            Ph.shareNetwork.regular.frame(width: 22, height: 22)
          }
          .buttonStyle(.variant(.plain, size: .iconLg))
          .accessibilityLabel(Text("Share folder"))
          Tip(id: "notes-compose", delay: .milliseconds(600)) {
            Button {
              log = "Started a new note"
            } label: {
              Ph.notePencil.regular.frame(width: 22, height: 22)
            }
            .buttonStyle(.variant(.plain, size: .iconLg))
            .accessibilityLabel(Text("New note"))
            .tipPopover(side: .bottom) {
              TipIcon { Ph.notePencil.duotone }
              TipContent {
                TipTitle("Start a note fast")
                TipDescription("Tap here any time to begin a new note.")
              }
              TipClose()
            }
          }
        }
        TipPinCard(id: "notes-pin")
        VStack(spacing: 0) {
          ForEach(Array(tipNotes.enumerated()), id: \.element.id) { index, note in
            Button {
              log = "Opened \(note.title)"
            } label: {
              VStack(alignment: .leading, spacing: 0) {
                Text(verbatim: note.title)
                  .textStyle(.base, weight: .semibold)
                  .foregroundStyle(Color.label)
                  .lineLimit(1)
                Text(verbatim: note.preview)
                  .textStyle(.sm)
                  .foregroundStyle(Color.labelSecondary)
                  .lineLimit(1)
              }
              .padding(.horizontal, 16)
              .padding(.vertical, 12)
              .frame(maxWidth: .infinity, alignment: .leading)
              .contentShape(Rectangle())
            }
            .buttonStyle(TipRowStyle())
            .overlay(alignment: .top) {
              if index > 0 {
                Color.separator.frame(height: 1)
              }
            }
          }
        }
        .clipShape(shape)
        .overlay { shape.strokeBorder(Color.separator) }
        Text(verbatim: log)
          .textStyle(.sm)
          .foregroundStyle(Color.labelSecondary)
          .frame(minHeight: 20)
          .accessibilityAddTraits(.updatesFrequently)
      }
    }
  }
}

struct TipRowStyle: ButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    configuration.label
      .background(configuration.isPressed ? Color.itemPressed : .clear)
  }
}

struct TipExample: View {
  @State private var key = 0
  @State private var ready = false

  var body: some View {
    VStack(spacing: 24) {
      if ready {
        TipNotesPage()
          .id(key)
      }
      Button("Show tips again") {
        Task {
          await resetTips(tipIDs)
          key += 1
        }
      }
      .buttonStyle(.variant(.secondary, size: .sm))
    }
    .task {
      await resetTips(tipIDs)
      ready = true
    }
  }
}
```

```kotlin
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Duotone
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.duotone.NotePencil
import com.adamglin.phosphoricons.duotone.PushPin
import com.adamglin.phosphoricons.regular.NotePencil
import com.adamglin.phosphoricons.regular.ShareNetwork
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonSize
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.PopupAlign
import com.esuiss.ui.components.PopupSide
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.TextVariant
import com.esuiss.ui.interactions.Tip
import com.esuiss.ui.interactions.TipAnchor
import com.esuiss.ui.interactions.TipCard
import com.esuiss.ui.interactions.TipClose
import com.esuiss.ui.interactions.TipContent
import com.esuiss.ui.interactions.TipDescription
import com.esuiss.ui.interactions.TipGroup
import com.esuiss.ui.interactions.TipIcon
import com.esuiss.ui.interactions.TipPopover
import com.esuiss.ui.interactions.TipTitle
import com.esuiss.ui.interactions.resetTips
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography
import kotlinx.coroutines.launch

class TipNote(val id: Int, val title: String, val preview: String)

val tipNotes = listOf(
    TipNote(1, "Groceries", "Milk, eggs, bread, coffee"),
    TipNote(2, "Trip ideas", "Lisbon, Porto, the coast road"),
    TipNote(3, "Book list", "Three novels for the summer"),
    TipNote(4, "Meeting notes", "Ship the beta on Friday"),
)

val tipIds = listOf("notes-pin", "notes-compose")

@Composable
fun TipPinCard() {
    TipCard {
        TipIcon {
            Icon(PhosphorIcons.Duotone.PushPin, contentDescription = null)
        }
        TipContent {
            TipTitle("Pin your favorites")
            TipDescription("Swipe right on a note to keep it at the top of the list.")
        }
        TipClose()
    }
}

@Composable
fun TipNotesPage() {
    val colors = EsuissTheme.colors
    var log by rememberSaveable { mutableStateOf("") }
    val shape = RoundedCornerShape(Radius.xl2)
    TipGroup {
        Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
            Row(
                modifier = Modifier.fillMaxWidth(),
                horizontalArrangement = Arrangement.SpaceBetween,
                verticalAlignment = Alignment.CenterVertically,
            ) {
                Text("Notes", variant = TextVariant.H1)
                Row(verticalAlignment = Alignment.CenterVertically) {
                    Button(onClick = { log = "Shared the folder" }, variant = ButtonVariant.Plain, size = ButtonSize.IconLg) {
                        Icon(PhosphorIcons.Regular.ShareNetwork, contentDescription = "Share folder", size = 22.dp)
                    }
                    Tip(id = "notes-compose", delayMillis = 600) {
                        TipAnchor {
                            Button(onClick = { log = "Started a new note" }, variant = ButtonVariant.Plain, size = ButtonSize.IconLg) {
                                Icon(PhosphorIcons.Regular.NotePencil, contentDescription = "New note", size = 22.dp)
                            }
                        }
                        TipPopover(side = PopupSide.Bottom, align = PopupAlign.End) {
                            TipIcon {
                                Icon(PhosphorIcons.Duotone.NotePencil, contentDescription = null)
                            }
                            TipContent {
                                TipTitle("Start a note fast")
                                TipDescription("Tap here any time to begin a new note.")
                            }
                            TipClose()
                        }
                    }
                }
            }
            Tip(id = "notes-pin") {
                TipPinCard()
            }
            Column(
                Modifier
                    .fillMaxWidth()
                    .clip(shape)
                    .border(1.dp, colors.separator, shape),
            ) {
                tipNotes.forEachIndexed { index, note ->
                    if (index > 0) Box(Modifier.fillMaxWidth().height(1.dp).background(colors.separator))
                    TipNoteRow(note) { log = "Opened ${note.title}" }
                }
            }
            Text(
                log,
                modifier = Modifier.heightIn(min = 20.dp).semantics { liveRegion = LiveRegionMode.Polite },
                style = Typography.sm.copy(color = colors.labelSecondary),
            )
        }
    }
}

@Composable
fun TipNoteRow(note: TipNote, onClick: () -> Unit) {
    val colors = EsuissTheme.colors
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    Column(
        Modifier
            .fillMaxWidth()
            .background(if (pressed) colors.itemPressed else Color.Transparent)
            .clickable(interactionSource = interaction, indication = null, role = Role.Button, onClick = onClick)
            .padding(horizontal = 16.dp, vertical = 12.dp),
    ) {
        Text(
            note.title,
            style = Typography.base.copy(fontWeight = FontWeight.SemiBold),
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
        )
        Text(
            note.preview,
            style = Typography.sm.copy(color = colors.labelSecondary),
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
        )
    }
}

@Composable
fun TipExample() {
    val scope = rememberCoroutineScope()
    var ready by remember { mutableStateOf(false) }
    var page by remember { mutableIntStateOf(0) }
    LaunchedEffect(Unit) {
        resetTips(tipIds)
        ready = true
    }
    Column(verticalArrangement = Arrangement.spacedBy(24.dp), horizontalAlignment = Alignment.CenterHorizontally) {
        if (ready) {
            key(page) { TipNotesPage() }
        }
        Button(
            "Show tips again",
            onClick = {
                scope.launch {
                    resetTips(tipIds)
                    page += 1
                }
            },
            variant = ButtonVariant.Secondary,
            size = ButtonSize.Sm,
        )
    }
}
```

`Tip` shows a hint until the user dismisses it, then records the dismissal under its `id` so it does not return. It renders either as a `TipCard` placed in the layout, or as a `TipPopover` with an arrow pointing at a `TipAnchor`. Touching the anchor also dismisses the tip.

`TipGroup` shows several tips one at a time in the order they mount, moving to the next when one is dismissed. Use tips to introduce a feature or gesture the user may not discover.

## Installation

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

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

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

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

### Manual

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-reanimated react-native-screens react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Icon](/docs/components/icon), [Text](/docs/components/text), and the primitives `components/primitives/popover.tsx`, `components/primitives/types.tsx`, `components/primitives/use-render.tsx`.

3. Copy the source into `components/interactions/tip.tsx`:

```tsx title="uniwind/components/interactions/tip.tsx"
import { cn } from "cn"
import { XIcon } from "phosphor-react-native"
import * as React from "react"
import {
  Platform,
  Pressable,
  useWindowDimensions,
  View,
  type ViewProps,
} from "react-native"
import {
  Easing,
  FadeInDown,
  FadeOut,
  Keyframe,
  ReduceMotion,
} from "react-native-reanimated"
import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens"
import * as PopoverPrimitive from "@/components/primitives/popover"
import type { Align, Side } from "@/components/primitives/types"
import { resolveClassName } from "@/components/primitives/use-render"
import { AnimatedView } from "@/components/ui/animated-view"
import { Icon, IconClassContext } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

type TipStorage = {
  getItem: (key: string) => string | null | Promise<string | null>
  setItem: (key: string, value: string) => void | Promise<void>
}

type TipContextProps = {
  id: string
  titleId: string
  visible: boolean
  dismiss: () => void
  anchorRef: React.RefObject<View | null>
}

type TipGroupContextProps = {
  current: string | null
  storage: TipStorage
  register: (id: string) => () => void
  resolve: (id: string, dismissed: boolean) => void
  complete: (id: string) => void
}

const TipContext = React.createContext<TipContextProps | null>(null)

const TipGroupContext = React.createContext<TipGroupContextProps | null>(null)

function useTip() {
  const context = React.useContext(TipContext)
  if (!context) {
    throw new Error("useTip must be used within a <Tip />")
  }
  return context
}

const STORAGE_PREFIX = "tip:"

const DISMISSED = "dismissed"

const memory = new Map<string, string>()

const memoryStorage: TipStorage = {
  getItem: (key) => memory.get(key) ?? null,
  setItem: (key, value) => {
    memory.set(key, value)
  },
}

async function resetTips(ids: string[], storage: TipStorage = memoryStorage) {
  try {
    await Promise.all(ids.map((id) => storage.setItem(STORAGE_PREFIX + id, "")))
    return true
  } catch {
    return false
  }
}

function TipGroup({
  storage = memoryStorage,
  children,
}: {
  storage?: TipStorage
  children?: React.ReactNode
}) {
  const [order, setOrder] = React.useState<string[]>([])
  const [resolved, setResolved] = React.useState<string[]>([])
  const [completed, setCompleted] = React.useState<string[]>([])

  let current: string | null = null
  for (const id of order) {
    if (completed.includes(id)) continue
    current = resolved.includes(id) ? id : null
    break
  }

  const register = React.useCallback((id: string) => {
    setOrder((ids) => (ids.includes(id) ? ids : [...ids, id]))
    return () => setOrder((ids) => ids.filter((entry) => entry !== id))
  }, [])

  const resolve = React.useCallback((id: string, dismissed: boolean) => {
    if (dismissed) setCompleted((ids) => [...ids, id])
    else setResolved((ids) => [...ids, id])
  }, [])

  const complete = React.useCallback((id: string) => {
    setCompleted((ids) => [...ids, id])
  }, [])

  const contextValue = React.useMemo(
    () => ({ current, storage, register, resolve, complete }),
    [current, storage, register, resolve, complete]
  )

  return (
    <TipGroupContext.Provider value={contextValue}>
      {children}
    </TipGroupContext.Provider>
  )
}

function Tip({
  id,
  delay = 0,
  onDismiss,
  children,
}: {
  id: string
  delay?: number
  onDismiss?: () => void
  children?: React.ReactNode
}) {
  const group = React.useContext(TipGroupContext)
  const storage = group?.storage ?? memoryStorage
  const [stored, setStored] = React.useState<boolean | null>(null)
  const [ready, setReady] = React.useState(false)
  const [dismissed, setDismissed] = React.useState(false)
  const anchorRef = React.useRef<View>(null)
  const titleId = `${React.useId()}-title`
  const register = group?.register
  const resolve = group?.resolve
  const complete = group?.complete
  const turn = !group || group.current === id
  const visible = ready && turn && !dismissed && stored === false

  React.useEffect(() => register?.(id), [register, id])

  React.useEffect(() => {
    let cancelled = false
    Promise.resolve(storage.getItem(STORAGE_PREFIX + id))
      .catch(() => null)
      .then((value) => {
        if (cancelled) return
        const isDismissed = value === DISMISSED
        setStored(isDismissed)
        resolve?.(id, isDismissed)
      })
    return () => {
      cancelled = true
    }
  }, [storage, id, resolve])

  React.useEffect(() => {
    if (stored !== false || !turn) return
    const timer = setTimeout(() => setReady(true), delay)
    return () => clearTimeout(timer)
  }, [stored, turn, delay])

  const dismiss = React.useCallback(() => {
    setDismissed(true)
    Promise.resolve(storage.setItem(STORAGE_PREFIX + id, DISMISSED)).catch(
      () => undefined
    )
    complete?.(id)
    onDismiss?.()
  }, [storage, id, complete, onDismiss])

  const contextValue = React.useMemo(
    () => ({ id, titleId, visible, dismiss, anchorRef }),
    [id, titleId, visible, dismiss]
  )

  return (
    <TipContext.Provider value={contextValue}>{children}</TipContext.Provider>
  )
}

const EASING = Easing.bezier(0.32, 0.72, 0, 1)

function TipCard({ className, ...props }: ViewProps) {
  const { visible } = useTip()
  if (!visible) return null
  return (
    <AnimatedView
      entering={FadeInDown.duration(400)
        .easing(EASING)
        .reduceMotion(ReduceMotion.System)}
      exiting={FadeOut.duration(300).reduceMotion(ReduceMotion.System)}
    >
      <View
        role="note"
        className={cn(
          "relative flex-row items-start gap-3 rounded-2xl bg-surface-secondary p-4 pe-12",
          className
        )}
        {...props}
      />
    </AnimatedView>
  )
}

function TipAnchor({ className, ...props }: ViewProps) {
  const { anchorRef, visible, dismiss } = useTip()
  return (
    <View
      ref={anchorRef}
      collapsable={false}
      onStartShouldSetResponderCapture={() => {
        if (visible) dismiss()
        return false
      }}
      className={cn("flex-row self-start", className)}
      {...props}
    />
  )
}

const FullWindowOverlay =
  Platform.OS === "ios" ? RNFullWindowOverlay : React.Fragment

const ENTERING = new Keyframe({
  0: { opacity: 0, transform: [{ scale: 0.9 }] },
  100: { opacity: 1, transform: [{ scale: 1 }], easing: EASING },
})
  .duration(300)
  .reduceMotion(ReduceMotion.System)

const EXITING = new Keyframe({
  0: { opacity: 1, transform: [{ scale: 1 }] },
  100: { opacity: 0, transform: [{ scale: 0.9 }], easing: EASING },
})
  .duration(300)
  .reduceMotion(ReduceMotion.System)

const ARROW_OFFSET: Record<Side, string> = {
  top: "-bottom-2",
  bottom: "-top-2",
  left: "-right-2",
  right: "-left-2",
}

function TipPopover({
  className,
  children,
  side = "bottom",
  align = "center",
  sideOffset = 10,
  portalHost,
  ...props
}: React.ComponentProps<typeof PopoverPrimitive.Popup> & {
  side?: Side
  align?: Align
  sideOffset?: number
  portalHost?: string
}) {
  const tip = useTip()
  const { visible, dismiss, anchorRef, titleId } = tip
  const { width } = useWindowDimensions()
  return (
    <PopoverPrimitive.Root
      open={visible}
      modal={false}
      onOpenChange={(open) => {
        if (!open) dismiss()
      }}
    >
      <PopoverPrimitive.Portal hostName={portalHost}>
        <FullWindowOverlay>
          <PopoverPrimitive.Positioner
            anchor={anchorRef}
            side={side}
            align={align}
            sideOffset={sideOffset}
            collisionPadding={16}
            className="z-50"
          >
            <PopoverPrimitive.Popup
              initialFocus={false}
              finalFocus={false}
              accessibilityLabelledBy={titleId}
              render={<AnimatedView entering={ENTERING} exiting={EXITING} />}
              className={(state) =>
                cn(
                  "relative flex-row items-start gap-3 rounded-2xl border border-label/5 bg-surface-raised p-4 pe-12 shadow-lg dark:border-label/10",
                  resolveClassName(className, state)
                )
              }
              style={{ width: Math.min(320, width - 32) }}
              {...props}
            >
              <PopoverPrimitive.Arrow
                className={(state) => cn("size-4", ARROW_OFFSET[state.side])}
              >
                <View className="size-3 translate-x-0.5 translate-y-0.5 rotate-45 rounded-xs bg-surface-raised" />
              </PopoverPrimitive.Arrow>
              <TipContext.Provider value={tip}>{children}</TipContext.Provider>
            </PopoverPrimitive.Popup>
          </PopoverPrimitive.Positioner>
        </FullWindowOverlay>
      </PopoverPrimitive.Portal>
    </PopoverPrimitive.Root>
  )
}

function TipIcon({ className, ...props }: ViewProps) {
  return (
    <IconClassContext.Provider value="size-8 text-accent">
      <View
        accessibilityElementsHidden
        importantForAccessibility="no-hide-descendants"
        className={cn("shrink-0 flex-row", className)}
        {...props}
      />
    </IconClassContext.Provider>
  )
}

function TipContent({ className, ...props }: ViewProps) {
  return (
    <View
      className={cn("min-w-0 flex-1 flex-col gap-0.5", className)}
      {...props}
    />
  )
}

function TipTitle({ className, ...props }: React.ComponentProps<typeof Text>) {
  const { titleId } = useTip()
  return (
    <Text
      nativeID={titleId}
      className={cn("text-base font-semibold", className)}
      {...props}
    />
  )
}

function TipDescription({
  className,
  ...props
}: React.ComponentProps<typeof Text>) {
  return (
    <Text
      className={cn("text-sm text-label-secondary", className)}
      {...props}
    />
  )
}

function TipClose({
  className,
  onPress,
  ...props
}: Omit<React.ComponentProps<typeof Pressable>, "children">) {
  const { dismiss } = useTip()
  return (
    <Pressable
      role="button"
      accessibilityLabel="Dismiss tip"
      hitSlop={6}
      onPress={(event) => {
        onPress?.(event)
        dismiss()
      }}
      className={cn(
        "absolute end-2 top-2 size-8 items-center justify-center rounded-full active:bg-item-pressed",
        className
      )}
      {...props}
    >
      <Icon as={XIcon} weight="bold" className="size-4 text-label-secondary" />
    </Pressable>
  )
}

export type { TipStorage }
export {
  resetTips,
  Tip,
  TipAnchor,
  TipCard,
  TipClose,
  TipContent,
  TipDescription,
  TipGroup,
  TipIcon,
  TipPopover,
  TipTitle,
  useTip,
}
```

4. 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-screens react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Icon](/docs/components/icon), [Text](/docs/components/text), and the primitives `components/primitives/popover.tsx`, `components/primitives/types.tsx`, `components/primitives/use-render.tsx`.

3. Copy the source into `components/interactions/tip.tsx`:

```tsx title="nativewind/components/interactions/tip.tsx"
import { cn } from "cn"
import { XIcon } from "phosphor-react-native"
import * as React from "react"
import {
  Platform,
  Pressable,
  useWindowDimensions,
  View,
  type ViewProps,
} from "react-native"
import {
  Easing,
  FadeInDown,
  FadeOut,
  Keyframe,
  ReduceMotion,
} from "react-native-reanimated"
import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens"
import * as PopoverPrimitive from "@/components/primitives/popover"
import type { Align, Side } from "@/components/primitives/types"
import { resolveClassName } from "@/components/primitives/use-render"
import { AnimatedView } from "@/components/ui/animated-view"
import { Icon, IconClassContext } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

type TipStorage = {
  getItem: (key: string) => string | null | Promise<string | null>
  setItem: (key: string, value: string) => void | Promise<void>
}

type TipContextProps = {
  id: string
  titleId: string
  visible: boolean
  dismiss: () => void
  anchorRef: React.RefObject<View | null>
}

type TipGroupContextProps = {
  current: string | null
  storage: TipStorage
  register: (id: string) => () => void
  resolve: (id: string, dismissed: boolean) => void
  complete: (id: string) => void
}

const TipContext = React.createContext<TipContextProps | null>(null)

const TipGroupContext = React.createContext<TipGroupContextProps | null>(null)

function useTip() {
  const context = React.useContext(TipContext)
  if (!context) {
    throw new Error("useTip must be used within a <Tip />")
  }
  return context
}

const STORAGE_PREFIX = "tip:"

const DISMISSED = "dismissed"

const memory = new Map<string, string>()

const memoryStorage: TipStorage = {
  getItem: (key) => memory.get(key) ?? null,
  setItem: (key, value) => {
    memory.set(key, value)
  },
}

async function resetTips(ids: string[], storage: TipStorage = memoryStorage) {
  try {
    await Promise.all(ids.map((id) => storage.setItem(STORAGE_PREFIX + id, "")))
    return true
  } catch {
    return false
  }
}

function TipGroup({
  storage = memoryStorage,
  children,
}: {
  storage?: TipStorage
  children?: React.ReactNode
}) {
  const [order, setOrder] = React.useState<string[]>([])
  const [resolved, setResolved] = React.useState<string[]>([])
  const [completed, setCompleted] = React.useState<string[]>([])

  let current: string | null = null
  for (const id of order) {
    if (completed.includes(id)) continue
    current = resolved.includes(id) ? id : null
    break
  }

  const register = React.useCallback((id: string) => {
    setOrder((ids) => (ids.includes(id) ? ids : [...ids, id]))
    return () => setOrder((ids) => ids.filter((entry) => entry !== id))
  }, [])

  const resolve = React.useCallback((id: string, dismissed: boolean) => {
    if (dismissed) setCompleted((ids) => [...ids, id])
    else setResolved((ids) => [...ids, id])
  }, [])

  const complete = React.useCallback((id: string) => {
    setCompleted((ids) => [...ids, id])
  }, [])

  const contextValue = React.useMemo(
    () => ({ current, storage, register, resolve, complete }),
    [current, storage, register, resolve, complete]
  )

  return (
    <TipGroupContext.Provider value={contextValue}>
      {children}
    </TipGroupContext.Provider>
  )
}

function Tip({
  id,
  delay = 0,
  onDismiss,
  children,
}: {
  id: string
  delay?: number
  onDismiss?: () => void
  children?: React.ReactNode
}) {
  const group = React.useContext(TipGroupContext)
  const storage = group?.storage ?? memoryStorage
  const [stored, setStored] = React.useState<boolean | null>(null)
  const [ready, setReady] = React.useState(false)
  const [dismissed, setDismissed] = React.useState(false)
  const anchorRef = React.useRef<View>(null)
  const titleId = `${React.useId()}-title`
  const register = group?.register
  const resolve = group?.resolve
  const complete = group?.complete
  const turn = !group || group.current === id
  const visible = ready && turn && !dismissed && stored === false

  React.useEffect(() => register?.(id), [register, id])

  React.useEffect(() => {
    let cancelled = false
    Promise.resolve(storage.getItem(STORAGE_PREFIX + id))
      .catch(() => null)
      .then((value) => {
        if (cancelled) return
        const isDismissed = value === DISMISSED
        setStored(isDismissed)
        resolve?.(id, isDismissed)
      })
    return () => {
      cancelled = true
    }
  }, [storage, id, resolve])

  React.useEffect(() => {
    if (stored !== false || !turn) return
    const timer = setTimeout(() => setReady(true), delay)
    return () => clearTimeout(timer)
  }, [stored, turn, delay])

  const dismiss = React.useCallback(() => {
    setDismissed(true)
    Promise.resolve(storage.setItem(STORAGE_PREFIX + id, DISMISSED)).catch(
      () => undefined
    )
    complete?.(id)
    onDismiss?.()
  }, [storage, id, complete, onDismiss])

  const contextValue = React.useMemo(
    () => ({ id, titleId, visible, dismiss, anchorRef }),
    [id, titleId, visible, dismiss]
  )

  return (
    <TipContext.Provider value={contextValue}>{children}</TipContext.Provider>
  )
}

const EASING = Easing.bezier(0.32, 0.72, 0, 1)

function TipCard({ className, ...props }: ViewProps) {
  const { visible } = useTip()
  if (!visible) return null
  return (
    <AnimatedView
      entering={FadeInDown.duration(400)
        .easing(EASING)
        .reduceMotion(ReduceMotion.System)}
      exiting={FadeOut.duration(300).reduceMotion(ReduceMotion.System)}
    >
      <View
        role="note"
        className={cn(
          "relative flex-row items-start gap-3 rounded-2xl bg-surface-secondary p-4 pe-12",
          className
        )}
        {...props}
      />
    </AnimatedView>
  )
}

function TipAnchor({ className, ...props }: ViewProps) {
  const { anchorRef, visible, dismiss } = useTip()
  return (
    <View
      ref={anchorRef}
      collapsable={false}
      onStartShouldSetResponderCapture={() => {
        if (visible) dismiss()
        return false
      }}
      className={cn("flex-row self-start", className)}
      {...props}
    />
  )
}

const FullWindowOverlay =
  Platform.OS === "ios" ? RNFullWindowOverlay : React.Fragment

const ENTERING = new Keyframe({
  0: { opacity: 0, transform: [{ scale: 0.9 }] },
  100: { opacity: 1, transform: [{ scale: 1 }], easing: EASING },
})
  .duration(300)
  .reduceMotion(ReduceMotion.System)

const EXITING = new Keyframe({
  0: { opacity: 1, transform: [{ scale: 1 }] },
  100: { opacity: 0, transform: [{ scale: 0.9 }], easing: EASING },
})
  .duration(300)
  .reduceMotion(ReduceMotion.System)

const ARROW_OFFSET: Record<Side, string> = {
  top: "-bottom-2",
  bottom: "-top-2",
  left: "-right-2",
  right: "-left-2",
}

function TipPopover({
  className,
  children,
  side = "bottom",
  align = "center",
  sideOffset = 10,
  portalHost,
  ...props
}: React.ComponentProps<typeof PopoverPrimitive.Popup> & {
  side?: Side
  align?: Align
  sideOffset?: number
  portalHost?: string
}) {
  const tip = useTip()
  const { visible, dismiss, anchorRef, titleId } = tip
  const { width } = useWindowDimensions()
  return (
    <PopoverPrimitive.Root
      open={visible}
      modal={false}
      onOpenChange={(open) => {
        if (!open) dismiss()
      }}
    >
      <PopoverPrimitive.Portal hostName={portalHost}>
        <FullWindowOverlay>
          <PopoverPrimitive.Positioner
            anchor={anchorRef}
            side={side}
            align={align}
            sideOffset={sideOffset}
            collisionPadding={16}
            className="z-50"
          >
            <PopoverPrimitive.Popup
              initialFocus={false}
              finalFocus={false}
              accessibilityLabelledBy={titleId}
              render={<AnimatedView entering={ENTERING} exiting={EXITING} />}
              className={(state) =>
                cn(
                  "relative flex-row items-start gap-3 rounded-2xl border border-label-5 bg-surface-raised p-4 pe-12 shadow-lg dark:border-label-10",
                  resolveClassName(className, state)
                )
              }
              style={{ width: Math.min(320, width - 32) }}
              {...props}
            >
              <PopoverPrimitive.Arrow
                className={(state) => cn("size-4", ARROW_OFFSET[state.side])}
              >
                <View className="size-3 translate-x-0.5 translate-y-0.5 rotate-45 rounded-xs bg-surface-raised" />
              </PopoverPrimitive.Arrow>
              <TipContext.Provider value={tip}>{children}</TipContext.Provider>
            </PopoverPrimitive.Popup>
          </PopoverPrimitive.Positioner>
        </FullWindowOverlay>
      </PopoverPrimitive.Portal>
    </PopoverPrimitive.Root>
  )
}

function TipIcon({ className, ...props }: ViewProps) {
  return (
    <IconClassContext.Provider value="size-8 text-accent">
      <View
        accessibilityElementsHidden
        importantForAccessibility="no-hide-descendants"
        className={cn("shrink-0 flex-row", className)}
        {...props}
      />
    </IconClassContext.Provider>
  )
}

function TipContent({ className, ...props }: ViewProps) {
  return (
    <View
      className={cn("min-w-0 flex-1 flex-col gap-0.5", className)}
      {...props}
    />
  )
}

function TipTitle({ className, ...props }: React.ComponentProps<typeof Text>) {
  const { titleId } = useTip()
  return (
    <Text
      nativeID={titleId}
      className={cn("text-base font-semibold", className)}
      {...props}
    />
  )
}

function TipDescription({
  className,
  ...props
}: React.ComponentProps<typeof Text>) {
  return (
    <Text
      className={cn("text-sm text-label-secondary", className)}
      {...props}
    />
  )
}

function TipClose({
  className,
  onPress,
  ...props
}: Omit<React.ComponentProps<typeof Pressable>, "children">) {
  const { dismiss } = useTip()
  return (
    <Pressable
      role="button"
      accessibilityLabel="Dismiss tip"
      hitSlop={6}
      onPress={(event) => {
        onPress?.(event)
        dismiss()
      }}
      className={cn(
        "absolute end-2 top-2 size-8 items-center justify-center rounded-full active:bg-item-pressed",
        className
      )}
      {...props}
    >
      <Icon as={XIcon} weight="bold" className="size-4 text-label-secondary" />
    </Pressable>
  )
}

export type { TipStorage }
export {
  resetTips,
  Tip,
  TipAnchor,
  TipCard,
  TipClose,
  TipContent,
  TipDescription,
  TipGroup,
  TipIcon,
  TipPopover,
  TipTitle,
  useTip,
}
```

4. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

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

2. Add the pieces it builds on: [Icon](/docs/components/icon), [Text](/docs/components/text), and the primitives `components/primitives/popover.tsx`, `components/primitives/types.tsx`, `components/primitives/use-render.tsx`.

3. Copy the source into `components/interactions/tip.tsx`:

```tsx title="stylesheet/components/interactions/tip.tsx"
import { XIcon } from "phosphor-react-native"
import * as React from "react"
import {
  Platform,
  Pressable,
  type StyleProp,
  StyleSheet,
  useWindowDimensions,
  View,
  type ViewProps,
  type ViewStyle,
} from "react-native"
import {
  Easing,
  FadeInDown,
  FadeOut,
  Keyframe,
  ReduceMotion,
} from "react-native-reanimated"
import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens"
import * as PopoverPrimitive from "@/components/primitives/popover"
import type { Align, Side } from "@/components/primitives/types"
import { AnimatedView } from "@/components/ui/animated-view"
import { Icon, IconSizeContext } from "@/components/ui/icon"
import { Text, TextStyleContext } from "@/components/ui/text"
import { fontSize, radius, shadow, useTheme } from "@/components/ui/theme"

type TipStorage = {
  getItem: (key: string) => string | null | Promise<string | null>
  setItem: (key: string, value: string) => void | Promise<void>
}

type TipContextProps = {
  id: string
  titleId: string
  visible: boolean
  dismiss: () => void
  anchorRef: React.RefObject<View | null>
}

type TipGroupContextProps = {
  current: string | null
  storage: TipStorage
  register: (id: string) => () => void
  resolve: (id: string, dismissed: boolean) => void
  complete: (id: string) => void
}

const TipContext = React.createContext<TipContextProps | null>(null)

const TipGroupContext = React.createContext<TipGroupContextProps | null>(null)

function useTip() {
  const context = React.useContext(TipContext)
  if (!context) {
    throw new Error("useTip must be used within a <Tip />")
  }
  return context
}

const styles = StyleSheet.create({
  card: {
    position: "relative",
    flexDirection: "row",
    alignItems: "flex-start",
    gap: 12,
    borderRadius: radius["2xl"],
    padding: 16,
    paddingEnd: 48,
  },
  anchor: { flexDirection: "row", alignSelf: "flex-start" },
  positioner: { zIndex: 50 },
  popup: {
    position: "relative",
    flexDirection: "row",
    alignItems: "flex-start",
    gap: 12,
    borderRadius: radius["2xl"],
    borderWidth: 1,
    padding: 16,
    paddingEnd: 48,
    ...shadow.lg,
  },
  arrow: { width: 16, height: 16 },
  arrowSquare: {
    width: 12,
    height: 12,
    borderRadius: radius.xs,
    transform: [{ translateX: 2 }, { translateY: 2 }, { rotate: "45deg" }],
  },
  icon: { flexShrink: 0, flexDirection: "row" },
  content: { minWidth: 0, flex: 1, flexDirection: "column", gap: 2 },
  title: { ...fontSize.base, fontWeight: "600" },
  description: { ...fontSize.sm },
  close: {
    position: "absolute",
    end: 8,
    top: 8,
    width: 32,
    height: 32,
    alignItems: "center",
    justifyContent: "center",
    borderRadius: radius.full,
  },
})

function withAlpha(hex: string, alpha: number) {
  const value = Number.parseInt(hex.slice(1), 16)
  return `rgba(${(value >> 16) & 255}, ${(value >> 8) & 255}, ${value & 255}, ${alpha})`
}

const STORAGE_PREFIX = "tip:"

const DISMISSED = "dismissed"

const memory = new Map<string, string>()

const memoryStorage: TipStorage = {
  getItem: (key) => memory.get(key) ?? null,
  setItem: (key, value) => {
    memory.set(key, value)
  },
}

async function resetTips(ids: string[], storage: TipStorage = memoryStorage) {
  try {
    await Promise.all(ids.map((id) => storage.setItem(STORAGE_PREFIX + id, "")))
    return true
  } catch {
    return false
  }
}

function TipGroup({
  storage = memoryStorage,
  children,
}: {
  storage?: TipStorage
  children?: React.ReactNode
}) {
  const [order, setOrder] = React.useState<string[]>([])
  const [resolved, setResolved] = React.useState<string[]>([])
  const [completed, setCompleted] = React.useState<string[]>([])

  let current: string | null = null
  for (const id of order) {
    if (completed.includes(id)) continue
    current = resolved.includes(id) ? id : null
    break
  }

  const register = React.useCallback((id: string) => {
    setOrder((ids) => (ids.includes(id) ? ids : [...ids, id]))
    return () => setOrder((ids) => ids.filter((entry) => entry !== id))
  }, [])

  const resolve = React.useCallback((id: string, dismissed: boolean) => {
    if (dismissed) setCompleted((ids) => [...ids, id])
    else setResolved((ids) => [...ids, id])
  }, [])

  const complete = React.useCallback((id: string) => {
    setCompleted((ids) => [...ids, id])
  }, [])

  const contextValue = React.useMemo(
    () => ({ current, storage, register, resolve, complete }),
    [current, storage, register, resolve, complete]
  )

  return (
    <TipGroupContext.Provider value={contextValue}>
      {children}
    </TipGroupContext.Provider>
  )
}

function Tip({
  id,
  delay = 0,
  onDismiss,
  children,
}: {
  id: string
  delay?: number
  onDismiss?: () => void
  children?: React.ReactNode
}) {
  const group = React.useContext(TipGroupContext)
  const storage = group?.storage ?? memoryStorage
  const [stored, setStored] = React.useState<boolean | null>(null)
  const [ready, setReady] = React.useState(false)
  const [dismissed, setDismissed] = React.useState(false)
  const anchorRef = React.useRef<View>(null)
  const titleId = `${React.useId()}-title`
  const register = group?.register
  const resolve = group?.resolve
  const complete = group?.complete
  const turn = !group || group.current === id
  const visible = ready && turn && !dismissed && stored === false

  React.useEffect(() => register?.(id), [register, id])

  React.useEffect(() => {
    let cancelled = false
    Promise.resolve(storage.getItem(STORAGE_PREFIX + id))
      .catch(() => null)
      .then((value) => {
        if (cancelled) return
        const isDismissed = value === DISMISSED
        setStored(isDismissed)
        resolve?.(id, isDismissed)
      })
    return () => {
      cancelled = true
    }
  }, [storage, id, resolve])

  React.useEffect(() => {
    if (stored !== false || !turn) return
    const timer = setTimeout(() => setReady(true), delay)
    return () => clearTimeout(timer)
  }, [stored, turn, delay])

  const dismiss = React.useCallback(() => {
    setDismissed(true)
    Promise.resolve(storage.setItem(STORAGE_PREFIX + id, DISMISSED)).catch(
      () => undefined
    )
    complete?.(id)
    onDismiss?.()
  }, [storage, id, complete, onDismiss])

  const contextValue = React.useMemo(
    () => ({ id, titleId, visible, dismiss, anchorRef }),
    [id, titleId, visible, dismiss]
  )

  return (
    <TipContext.Provider value={contextValue}>{children}</TipContext.Provider>
  )
}

const EASING = Easing.bezier(0.32, 0.72, 0, 1)

function TipCard({ style, ...props }: ViewProps) {
  const { colors } = useTheme()
  const { visible } = useTip()
  if (!visible) return null
  return (
    <AnimatedView
      entering={FadeInDown.duration(400)
        .easing(EASING)
        .reduceMotion(ReduceMotion.System)}
      exiting={FadeOut.duration(300).reduceMotion(ReduceMotion.System)}
    >
      <View
        role="note"
        style={[
          styles.card,
          { backgroundColor: colors.surfaceSecondary },
          style,
        ]}
        {...props}
      />
    </AnimatedView>
  )
}

function TipAnchor({ style, ...props }: ViewProps) {
  const { anchorRef, visible, dismiss } = useTip()
  return (
    <View
      ref={anchorRef}
      collapsable={false}
      onStartShouldSetResponderCapture={() => {
        if (visible) dismiss()
        return false
      }}
      style={[styles.anchor, style]}
      {...props}
    />
  )
}

const FullWindowOverlay =
  Platform.OS === "ios" ? RNFullWindowOverlay : React.Fragment

const ENTERING = new Keyframe({
  0: { opacity: 0, transform: [{ scale: 0.9 }] },
  100: { opacity: 1, transform: [{ scale: 1 }], easing: EASING },
})
  .duration(300)
  .reduceMotion(ReduceMotion.System)

const EXITING = new Keyframe({
  0: { opacity: 1, transform: [{ scale: 1 }] },
  100: { opacity: 0, transform: [{ scale: 0.9 }], easing: EASING },
})
  .duration(300)
  .reduceMotion(ReduceMotion.System)

const ARROW_OFFSET: Record<Side, ViewStyle> = {
  top: { bottom: -8 },
  bottom: { top: -8 },
  left: { right: -8 },
  right: { left: -8 },
}

function TipPopover({
  style,
  children,
  side = "bottom",
  align = "center",
  sideOffset = 10,
  portalHost,
  ...props
}: React.ComponentProps<typeof PopoverPrimitive.Popup> & {
  side?: Side
  align?: Align
  sideOffset?: number
  portalHost?: string
}) {
  const { colors, isDark } = useTheme()
  const tip = useTip()
  const { visible, dismiss, anchorRef, titleId } = tip
  const { width } = useWindowDimensions()
  return (
    <PopoverPrimitive.Root
      open={visible}
      modal={false}
      onOpenChange={(open) => {
        if (!open) dismiss()
      }}
    >
      <PopoverPrimitive.Portal hostName={portalHost}>
        <FullWindowOverlay>
          <PopoverPrimitive.Positioner
            anchor={anchorRef}
            side={side}
            align={align}
            sideOffset={sideOffset}
            collisionPadding={16}
            style={styles.positioner}
          >
            <PopoverPrimitive.Popup
              initialFocus={false}
              finalFocus={false}
              accessibilityLabelledBy={titleId}
              render={<AnimatedView entering={ENTERING} exiting={EXITING} />}
              style={(state): StyleProp<ViewStyle> => [
                styles.popup,
                {
                  width: Math.min(320, width - 32),
                  borderColor: withAlpha(colors.label, isDark ? 0.1 : 0.05),
                  backgroundColor: colors.surfaceRaised,
                },
                typeof style === "function" ? style(state) : style,
              ]}
              {...props}
            >
              <PopoverPrimitive.Arrow
                style={(state) => [styles.arrow, ARROW_OFFSET[state.side]]}
              >
                <View
                  style={[
                    styles.arrowSquare,
                    { backgroundColor: colors.surfaceRaised },
                  ]}
                />
              </PopoverPrimitive.Arrow>
              <TextStyleContext.Provider
                value={{ ...fontSize.base, color: colors.label }}
              >
                <TipContext.Provider value={tip}>
                  {children}
                </TipContext.Provider>
              </TextStyleContext.Provider>
            </PopoverPrimitive.Popup>
          </PopoverPrimitive.Positioner>
        </FullWindowOverlay>
      </PopoverPrimitive.Portal>
    </PopoverPrimitive.Root>
  )
}

function TipIcon({ style, ...props }: ViewProps) {
  const { colors } = useTheme()
  return (
    <TextStyleContext.Provider value={{ color: colors.accent }}>
      <IconSizeContext.Provider value={32}>
        <View
          accessibilityElementsHidden
          importantForAccessibility="no-hide-descendants"
          style={[styles.icon, style]}
          {...props}
        />
      </IconSizeContext.Provider>
    </TextStyleContext.Provider>
  )
}

function TipContent({ style, ...props }: ViewProps) {
  return <View style={[styles.content, style]} {...props} />
}

function TipTitle({ style, ...props }: React.ComponentProps<typeof Text>) {
  const { colors } = useTheme()
  const { titleId } = useTip()
  return (
    <Text
      nativeID={titleId}
      style={[styles.title, { color: colors.label }, style]}
      {...props}
    />
  )
}

function TipDescription({
  style,
  ...props
}: React.ComponentProps<typeof Text>) {
  const { colors } = useTheme()
  return (
    <Text
      style={[styles.description, { color: colors.labelSecondary }, style]}
      {...props}
    />
  )
}

function TipClose({
  style,
  onPress,
  ...props
}: Omit<React.ComponentProps<typeof Pressable>, "children">) {
  const { colors } = useTheme()
  const { dismiss } = useTip()
  return (
    <Pressable
      role="button"
      accessibilityLabel="Dismiss tip"
      hitSlop={6}
      onPress={(event) => {
        onPress?.(event)
        dismiss()
      }}
      style={(state): StyleProp<ViewStyle> => [
        styles.close,
        state.pressed && { backgroundColor: colors.itemPressed },
        typeof style === "function" ? style(state) : style,
      ]}
      {...props}
    >
      <Icon as={XIcon} weight="bold" size={16} color={colors.labelSecondary} />
    </Pressable>
  )
}

export type { TipStorage }
export {
  resetTips,
  Tip,
  TipAnchor,
  TipCard,
  TipClose,
  TipContent,
  TipDescription,
  TipGroup,
  TipIcon,
  TipPopover,
  TipTitle,
  useTip,
}
```

4. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

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

3. Copy the theme files every view uses:

```swift 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)
}
```

4. Copy the source and every file it uses:

```swift title="swift/Interactions/Tip.swift"
import PhosphorSwift
import SwiftUI

public protocol TipStorage: Sendable {
  func getItem(_ key: String) async -> String?
  func setItem(_ key: String, _ value: String?) async
}

public actor InMemoryTipStorage: TipStorage {
  public static let shared = InMemoryTipStorage()

  private var values: [String: String] = [:]

  public init() {}

  public func getItem(_ key: String) async -> String? {
    values[key]
  }

  public func setItem(_ key: String, _ value: String?) async {
    values[key] = value
  }
}

enum TipKeys {
  static func key(_ id: String) -> String { "tip:" + id }
  static let dismissed = "dismissed"
}

public func resetTips(_ ids: [String], storage: any TipStorage = InMemoryTipStorage.shared) async {
  for id in ids {
    await storage.setItem(TipKeys.key(id), nil)
  }
}

enum TipQueue {
  static func current(order: [String], loaded: Set<String>, skipped: Set<String>) -> String? {
    for id in order {
      guard loaded.contains(id) else { return nil }
      if !skipped.contains(id) { return id }
    }
    return nil
  }
}

struct TipGroupContext {
  var current: String?
  var storage: any TipStorage
  var complete: (String) -> Void
}

struct TipContext {
  var visible: Bool
  var dismiss: () -> Void
}

extension EnvironmentValues {
  @Entry var tipGroup: TipGroupContext?
  @Entry var tip: TipContext?
}

struct TipIdsKey: PreferenceKey {
  static var defaultValue: [String] { [] }

  static func reduce(value: inout [String], nextValue: () -> [String]) {
    value.append(contentsOf: nextValue())
  }
}

let tipAnimation = Animation.timingCurve(0.32, 0.72, 0, 1, duration: 0.4)

public struct TipGroup<Content: View>: View {
  let storage: any TipStorage
  let content: Content

  @State private var order: [String] = []
  @State private var loaded: Set<String> = []
  @State private var skipped: Set<String> = []

  public init(storage: any TipStorage = InMemoryTipStorage.shared, @ViewBuilder content: () -> Content) {
    self.storage = storage
    self.content = content()
  }

  public var body: some View {
    let context = TipGroupContext(
      current: TipQueue.current(order: order, loaded: loaded, skipped: skipped),
      storage: storage,
      complete: { id in skipped.insert(id) }
    )
    content
      .environment(\.tipGroup, context)
      .onPreferenceChange(TipIdsKey.self) { ids in
        order = ids
        load(ids)
      }
      .transformPreference(TipIdsKey.self) { $0 = [] }
  }

  private func load(_ ids: [String]) {
    let storage = storage
    for id in ids where !loaded.contains(id) {
      Task { @MainActor in
        let value = await storage.getItem(TipKeys.key(id))
        if value == TipKeys.dismissed { skipped.insert(id) }
        loaded.insert(id)
      }
    }
  }
}

public struct Tip<Content: View>: View {
  let id: String
  let delay: Duration
  let storage: (any TipStorage)?
  let onDismiss: (() -> Void)?
  let content: Content

  @State private var ready = false
  @State private var dismissed = false
  @Environment(\.tipGroup) private var group
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init(
    id: String,
    delay: Duration = .zero,
    storage: (any TipStorage)? = nil,
    onDismiss: (() -> Void)? = nil,
    @ViewBuilder content: () -> Content
  ) {
    self.id = id
    self.delay = delay
    self.storage = storage
    self.onDismiss = onDismiss
    self.content = content()
  }

  private var resolvedStorage: any TipStorage {
    storage ?? group?.storage ?? InMemoryTipStorage.shared
  }

  private var turn: Bool {
    group.map { $0.current == id } ?? true
  }

  public var body: some View {
    let visible = ready && turn && !dismissed
    ZStack(alignment: .topLeading) {
      Color.clear
        .frame(width: 0, height: 0)
        .preference(key: TipIdsKey.self, value: [id])
        .task(id: turn) {
          await reveal()
        }
      content
        .environment(\.tip, TipContext(visible: visible, dismiss: dismiss))
    }
  }

  private func reveal() async {
    guard turn, !ready else { return }
    if group == nil, await resolvedStorage.getItem(TipKeys.key(id)) == TipKeys.dismissed { return }
    if delay > .zero {
      try? await Task.sleep(for: delay)
      guard !Task.isCancelled else { return }
    }
    animate { ready = true }
  }

  private func dismiss() {
    guard !dismissed else { return }
    animate { dismissed = true }
    let storage = resolvedStorage
    let key = TipKeys.key(id)
    Task { await storage.setItem(key, TipKeys.dismissed) }
    group?.complete(id)
    onDismiss?()
  }

  private func animate(_ change: () -> Void) {
    if reduceMotion {
      change()
    } else {
      withAnimation(tipAnimation, change)
    }
  }
}

struct TipCloseKey: LayoutValueKey {
  static let defaultValue = false
}

struct TipRowLayout: Layout {
  var padding: CGFloat = 16
  var trailing: CGFloat = 48
  var spacing: CGFloat = 12

  private func widths(_ subviews: Subviews, width: CGFloat?) -> [CGFloat?] {
    let row = subviews.filter { !$0[TipCloseKey.self] }
    let fixed = row.map { $0.priority > 0 ? nil : $0.sizeThatFits(.unspecified).width } as [CGFloat?]
    guard let width else { return fixed }
    let inner = width - padding - trailing - spacing * CGFloat(max(row.count - 1, 0))
    let used = fixed.compactMap { $0 }.reduce(0, +)
    let flexible = fixed.filter { $0 == nil }.count
    let share = flexible > 0 ? max(0, inner - used) / CGFloat(flexible) : 0
    return fixed.map { $0 ?? share }
  }

  func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
    let row = subviews.filter { !$0[TipCloseKey.self] }
    let sizes = zip(row, widths(subviews, width: proposal.width)).map { view, width in
      view.sizeThatFits(ProposedViewSize(width: width, height: nil))
    }
    let height = sizes.map(\.height).max() ?? 0
    let contentWidth = sizes.map(\.width).reduce(0, +) + spacing * CGFloat(max(row.count - 1, 0))
    return CGSize(
      width: proposal.width ?? contentWidth + padding + trailing,
      height: max(height + padding * 2, 48)
    )
  }

  func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
    let row = subviews.filter { !$0[TipCloseKey.self] }
    var x = bounds.minX + padding
    for (view, width) in zip(row, widths(subviews, width: bounds.width)) {
      let size = view.sizeThatFits(ProposedViewSize(width: width, height: nil))
      view.place(at: CGPoint(x: x, y: bounds.minY + padding), anchor: .topLeading, proposal: ProposedViewSize(width: width, height: size.height))
      x += (width ?? size.width) + spacing
    }
    for view in subviews where view[TipCloseKey.self] {
      view.place(at: CGPoint(x: bounds.maxX - 8, y: bounds.minY + 8), anchor: .topTrailing, proposal: ProposedViewSize(width: 32, height: 32))
    }
  }
}

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

  @Environment(\.tip) private var tip

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

  public var body: some View {
    if tip?.visible ?? false {
      TipRowLayout {
        content
      }
      .foregroundStyle(Color.label)
      .frame(maxWidth: .infinity, alignment: .leading)
      .background(Color.surfaceSecondary, in: RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous))
      .accessibilityElement(children: .contain)
      .transition(.opacity.combined(with: .offset(y: -8)))
    }
  }
}

extension View {
  public func tipPopover<Content: View>(
    side: Edge = .bottom,
    @ViewBuilder content: @escaping () -> Content
  ) -> some View {
    modifier(TipPopoverPresenter(side: side, popover: content))
  }
}

struct TipPopoverPresenter<Popover: View>: ViewModifier {
  let side: Edge
  @ViewBuilder let popover: () -> Popover

  @Environment(\.tip) private var tip

  private var arrowEdge: Edge {
    switch side {
    case .top: .bottom
    case .bottom: .top
    case .leading: .trailing
    case .trailing: .leading
    }
  }

  func body(content: Content) -> some View {
    let tip = tip
    let visible = tip?.visible ?? false
    content
      .simultaneousGesture(TapGesture().onEnded {
        if visible { tip?.dismiss() }
      })
      .popover(
        isPresented: Binding(
          get: { visible },
          set: { if !$0 { tip?.dismiss() } }
        ),
        arrowEdge: arrowEdge
      ) {
        TipPopoverSurface { popover() }
          .environment(\.tip, tip)
          .presentationCompactAdaptation(.popover)
          .presentationBackground(Color.surfaceRaised)
      }
  }
}

struct TipPopoverSurface<Content: View>: View {
  @ViewBuilder let content: () -> Content

  var body: some View {
    TipRowLayout {
      content()
    }
    .foregroundStyle(Color.label)
    .frame(width: 320, alignment: .leading)
    .fixedSize(horizontal: false, vertical: true)
    .accessibilityElement(children: .contain)
  }
}

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

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

  public var body: some View {
    content
      .frame(width: 32, height: 32)
      .foregroundStyle(Color.accent)
      .accessibilityHidden(true)
  }
}

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

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

  public var body: some View {
    VStack(alignment: .leading, spacing: 2) {
      content
    }
    .frame(maxWidth: .infinity, alignment: .leading)
    .layoutPriority(1)
  }
}

public struct TipTitle: View {
  let text: Text

  public init(_ title: LocalizedStringKey) {
    text = Text(title)
  }

  public init(verbatim title: String) {
    text = Text(verbatim: title)
  }

  public var body: some View {
    text
      .scaledTextStyle(.base, weight: .semibold)
      .foregroundStyle(Color.label)
      .fixedSize(horizontal: false, vertical: true)
      .accessibilityAddTraits(.isHeader)
  }
}

public struct TipDescription: View {
  let text: Text

  public init(_ description: LocalizedStringKey) {
    text = Text(description)
  }

  public init(verbatim description: String) {
    text = Text(verbatim: description)
  }

  public var body: some View {
    text
      .scaledTextStyle(.sm)
      .foregroundStyle(Color.labelSecondary)
      .fixedSize(horizontal: false, vertical: true)
  }
}

public struct TipClose: View {
  @Environment(\.tip) private var tip

  public init() {}

  public var body: some View {
    Button {
      tip?.dismiss()
    } label: {
      Ph.x.bold
        .frame(width: 16, height: 16)
    }
    .buttonStyle(TipCloseStyle())
    .accessibilityLabel(Text("Dismiss tip"))
    .layoutValue(key: TipCloseKey.self, value: true)
  }
}

private struct TipCloseStyle: ButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    configuration.label
      .foregroundStyle(configuration.isPressed ? Color.label : .labelSecondary)
      .frame(width: 32, height: 32)
      .background(configuration.isPressed ? Color.itemPressed : .clear, in: Circle())
      .contentShape(Circle().inset(by: -6))
  }
}
```

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

public enum TextVariant: CaseIterable, Sendable {
  case `default`, h1, h2, h3, h4, p, blockquote, code, lead, large, small, muted

  var style: TextStyle {
    switch self {
    case .h1: .xl3
    case .h2: .xl2
    case .h3: .xl
    case .h4, .lead, .large: .lg
    case .code, .small, .muted: .sm
    default: .base
    }
  }

  var weight: Font.Weight {
    switch self {
    case .h1, .h2: .bold
    case .h3, .h4, .large, .code: .semibold
    case .small: .medium
    default: .regular
    }
  }

  var color: Color {
    switch self {
    case .lead, .muted: .labelSecondary
    default: .label
    }
  }

  var isHeading: Bool {
    switch self {
    case .h1, .h2, .h3, .h4: true
    default: false
    }
  }
}

private struct TextVariantModifier: ViewModifier {
  let variant: TextVariant

  func body(content: Content) -> some View {
    switch variant {
    case .blockquote:
      styled(content)
        .italic()
        .padding(.leading, 12)
        .overlay(alignment: .leading) {
          Rectangle().fill(Color.separator).frame(width: 2)
        }
        .padding(.top, 16)
    case .code:
      styled(content)
        .monospaced()
        .padding(.horizontal, 4)
        .padding(.vertical, 2)
        .background(Color.surfaceSecondary, in: RoundedRectangle(cornerRadius: Radius.sm, style: .continuous))
    case .p:
      styled(content).padding(.top, 12)
    default:
      styled(content)
    }
  }

  private func styled(_ content: Content) -> some View {
    content
      .scaledTextStyle(variant.style, weight: variant.weight)
      .foregroundStyle(variant.color)
      .accessibilityAddTraits(variant.isHeading ? .isHeader : [])
  }
}

extension View {
  public func textVariant(_ variant: TextVariant) -> some View {
    modifier(TextVariantModifier(variant: variant))
  }
}

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

  @ScaledMetric(relativeTo: .body) private var scale: CGFloat = 1

  func body(content: Content) -> some View {
    let size = style.size * scale
    let lineSpacing = max(0, style.lineHeight * scale - UIFont.systemFont(ofSize: size).lineHeight)
    content
      .font(.system(size: size, weight: weight))
      .tracking(style.letterSpacing)
      .lineSpacing(lineSpacing)
      .padding(.vertical, lineSpacing / 2)
  }
}

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

  func scaledFont(_ style: TextStyle, weight: Font.Weight = .regular) -> some View {
    modifier(ScaledFontModifier(style: style, weight: weight))
  }
}

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

  @ScaledMetric(relativeTo: .body) private var scale: CGFloat = 1

  func body(content: Content) -> some View {
    content
      .font(.system(size: style.size * scale, weight: weight))
      .tracking(style.letterSpacing)
  }
}
```

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:

```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/interactions/Tip.kt"
package com.esuiss.ui.interactions

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
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.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathOperation
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Bold
import com.adamglin.phosphoricons.bold.X
import com.esuiss.ui.components.AnchoredPositionProvider
import com.esuiss.ui.components.FormEasing
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.LocalContentColor
import com.esuiss.ui.components.LocalContentStyle
import com.esuiss.ui.components.LocalIconSize
import com.esuiss.ui.components.PopupAlign
import com.esuiss.ui.components.PopupInset
import com.esuiss.ui.components.PopupMargin
import com.esuiss.ui.components.PopupPlacement
import com.esuiss.ui.components.PopupSide
import com.esuiss.ui.components.formReducedMotion
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Shadow
import com.esuiss.ui.theme.Typography
import com.esuiss.ui.theme.shadow
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

interface TipStorage {
    suspend fun getItem(key: String): String?
    suspend fun setItem(key: String, value: String)
}

class MemoryTipStorage : TipStorage {
    private val items = mutableMapOf<String, String>()

    override suspend fun getItem(key: String): String? = synchronized(items) { items[key] }

    override suspend fun setItem(key: String, value: String) {
        synchronized(items) { items[key] = value }
    }
}

private const val StoragePrefix = "tip:"

private const val Dismissed = "dismissed"

private val DefaultTipStorage = MemoryTipStorage()

private val LocalTipStorage = staticCompositionLocalOf<TipStorage> { DefaultTipStorage }

suspend fun resetTips(ids: List<String>, storage: TipStorage = DefaultTipStorage) {
    ids.forEach { storage.setItem(StoragePrefix + it, "") }
}

@Stable
private class TipGroupContext {
    val order = mutableStateListOf<String>()
    val completed = mutableStateListOf<String>()

    val current: String?
        get() = order.firstOrNull { it !in completed }
}

private val LocalTipGroup = staticCompositionLocalOf<TipGroupContext?> { null }

@Stable
private class TipContext(val dismiss: () -> Unit) {
    var visible by mutableStateOf(false)
    var anchor by mutableStateOf<Rect?>(null)
}

private val LocalTip = staticCompositionLocalOf<TipContext?> { null }

@Composable
private fun tip(): TipContext = LocalTip.current ?: error("Tip parts must be used within a Tip")

private object TipCloseSlot

private object TipContentSlot

@Composable
fun TipGroup(
    storage: TipStorage = LocalTipStorage.current,
    content: @Composable () -> Unit,
) {
    val group = remember { TipGroupContext() }
    CompositionLocalProvider(
        LocalTipGroup provides group,
        LocalTipStorage provides storage,
        content = content,
    )
}

@Composable
fun Tip(
    id: String,
    delayMillis: Long = 0,
    onDismiss: () -> Unit = {},
    content: @Composable () -> Unit,
) {
    val group = LocalTipGroup.current
    val storage = LocalTipStorage.current
    val scope = rememberCoroutineScope()
    val currentOnDismiss by rememberUpdatedState(onDismiss)
    var stored by remember(id, storage) { mutableStateOf<Boolean?>(null) }
    var ready by remember(id) { mutableStateOf(false) }
    var dismissed by remember(id) { mutableStateOf(false) }
    val context = remember(id, storage) {
        TipContext(
            dismiss = {
                if (!dismissed) {
                    dismissed = true
                    scope.launch { storage.setItem(StoragePrefix + id, Dismissed) }
                    group?.completed?.add(id)
                    currentOnDismiss()
                }
            },
        )
    }
    DisposableEffect(group, id) {
        if (group != null && id !in group.order) group.order.add(id)
        onDispose { group?.order?.remove(id) }
    }
    LaunchedEffect(id, storage) {
        val value = storage.getItem(StoragePrefix + id) == Dismissed
        stored = value
        if (value && group != null && id !in group.completed) group.completed.add(id)
    }
    val turn = group == null || group.current == id
    LaunchedEffect(id, turn, stored, delayMillis) {
        if (stored != false || !turn) return@LaunchedEffect
        delay(delayMillis)
        ready = true
    }
    context.visible = ready && turn && !dismissed && stored == false
    CompositionLocalProvider(LocalTip provides context, content = content)
}

@Composable
private fun TipSurface(
    modifier: Modifier,
    content: @Composable () -> Unit,
) {
    Layout(content = content, modifier = modifier) { measurables, constraints ->
        val padding = 16.dp.roundToPx()
        val end = 48.dp.roundToPx()
        val gap = 12.dp.roundToPx()
        val closeOffset = 8.dp.roundToPx()
        val width = if (constraints.hasBoundedWidth) constraints.maxWidth else constraints.minWidth
        val inner = (width - padding - end).coerceAtLeast(0)
        val flow = measurables.filter { it.layoutId != TipCloseSlot }
        val fills = flow.count { it.layoutId == TipContentSlot }
        val placeables = arrayOfNulls<Placeable>(measurables.size)
        var used = gap * (flow.size - 1).coerceAtLeast(0)
        measurables.forEachIndexed { index, measurable ->
            if (measurable.layoutId == TipCloseSlot) {
                placeables[index] = measurable.measure(Constraints())
            } else if (measurable.layoutId != TipContentSlot) {
                val placeable = measurable.measure(Constraints(maxWidth = (inner - used).coerceAtLeast(0)))
                placeables[index] = placeable
                used += placeable.width
            }
        }
        val remaining = (inner - used).coerceAtLeast(0)
        measurables.forEachIndexed { index, measurable ->
            if (measurable.layoutId == TipContentSlot) {
                val share = if (fills == 0) 0 else remaining / fills
                placeables[index] = measurable.measure(Constraints(minWidth = share, maxWidth = share))
            }
        }
        val contentHeight = measurables.indices
            .filter { measurables[it].layoutId != TipCloseSlot }
            .maxOfOrNull { placeables[it]?.height ?: 0 } ?: 0
        val closeHeight = measurables.indices
            .filter { measurables[it].layoutId == TipCloseSlot }
            .maxOfOrNull { (placeables[it]?.height ?: 0) + closeOffset } ?: 0
        val height = constraints.constrainHeightSafe(maxOf(contentHeight + padding * 2, closeHeight))
        layout(width, height) {
            var x = padding
            measurables.forEachIndexed { index, measurable ->
                val placeable = placeables[index] ?: return@forEachIndexed
                if (measurable.layoutId == TipCloseSlot) {
                    placeable.placeRelative(width - closeOffset - placeable.width, closeOffset)
                } else {
                    placeable.placeRelative(x, padding)
                    x += placeable.width + gap
                }
            }
        }
    }
}

private fun Constraints.constrainHeightSafe(height: Int): Int = height.coerceIn(minHeight, maxHeight)

@Composable
fun TipCard(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit,
) {
    val context = tip()
    val colors = EsuissTheme.colors
    val reduced = formReducedMotion()
    val slide = with(LocalDensity.current) { 8.dp.roundToPx() }
    AnimatedVisibility(
        visible = context.visible,
        enter = if (reduced) {
            fadeIn(tween(0))
        } else {
            expandVertically(tween(400, easing = FormEasing)) +
                fadeIn(tween(400, easing = FormEasing)) +
                slideInVertically(tween(400, easing = FormEasing)) { -slide }
        },
        exit = if (reduced) {
            fadeOut(tween(0))
        } else {
            shrinkVertically(tween(300, easing = FormEasing)) + fadeOut(tween(300, easing = FormEasing))
        },
    ) {
        val shape = RoundedCornerShape(Radius.xl2)
        TipProvider(context) {
            TipSurface(
                modifier = modifier
                    .fillMaxWidth()
                    .background(colors.surfaceSecondary, shape),
                content = content,
            )
        }
    }
}

@Composable
private fun TipProvider(context: TipContext, content: @Composable () -> Unit) {
    val colors = EsuissTheme.colors
    CompositionLocalProvider(
        LocalTip provides context,
        LocalContentStyle provides Typography.base.copy(color = colors.label),
        LocalContentColor provides colors.label,
        content = content,
    )
}

@Composable
fun TipAnchor(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit,
) {
    val context = tip()
    Box(
        modifier = modifier
            .onGloballyPositioned { context.anchor = it.boundsInWindow() }
            .pointerInput(context) {
                awaitEachGesture {
                    awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
                    val up = waitForUpOrCancellation(PointerEventPass.Initial)
                    if (up != null && context.visible) context.dismiss()
                }
            },
    ) {
        content()
    }
}

private val TipArrow = 8.dp

private val TipShadow = Shadow(0.dp, 12.dp, 24.dp, Color.Black.copy(alpha = 0.14f))

@Stable
private class TipPlacement {
    var side by mutableStateOf(PopupSide.Bottom)
    var arrow by mutableFloatStateOf(0f)
}

private class TipPositionProvider(
    private val anchor: Rect,
    private val delegate: AnchoredPositionProvider,
    private val inset: Int,
    private val arrow: Int,
    private val edge: Int,
    private val requested: PopupSide,
    private val placement: TipPlacement,
) : PopupPositionProvider {
    override fun calculatePosition(
        anchorBounds: IntRect,
        windowSize: IntSize,
        layoutDirection: LayoutDirection,
        popupContentSize: IntSize,
    ): IntOffset {
        val bounds = IntRect(anchor.left.toInt(), anchor.top.toInt(), anchor.right.toInt(), anchor.bottom.toInt())
        val position = delegate.calculatePosition(bounds, windowSize, layoutDirection, popupContentSize)
        val left = position.x + inset
        val top = position.y + inset
        val width = popupContentSize.width - inset * 2
        val height = popupContentSize.height - inset * 2
        val vertical = requested == PopupSide.Top || requested == PopupSide.Bottom
        placement.side = when {
            vertical && top >= bounds.bottom -> PopupSide.Bottom
            vertical -> PopupSide.Top
            left >= bounds.right -> PopupSide.Right
            else -> PopupSide.Left
        }
        placement.arrow = if (vertical) {
            (bounds.center.x - left).toFloat().coerceIn((edge + arrow).toFloat(), (width - edge - arrow).toFloat().coerceAtLeast((edge + arrow).toFloat()))
        } else {
            (bounds.center.y - top).toFloat().coerceIn((edge + arrow).toFloat(), (height - edge - arrow).toFloat().coerceAtLeast((edge + arrow).toFloat()))
        }
        return position
    }
}

private class TipBubbleShape(
    private val side: PopupSide,
    private val arrowCenter: Float,
    private val radius: Dp,
) : Shape {
    override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline {
        val arrow = with(density) { TipArrow.toPx() }
        val corner = with(density) { radius.toPx() }
        val body = when (side) {
            PopupSide.Bottom -> Rect(0f, arrow, size.width, size.height)
            PopupSide.Top -> Rect(0f, 0f, size.width, size.height - arrow)
            PopupSide.Right -> Rect(arrow, 0f, size.width, size.height)
            PopupSide.Left -> Rect(0f, 0f, size.width - arrow, size.height)
        }
        val rect = Path().apply { addRoundRect(RoundRect(body, CornerRadius(corner))) }
        val center = arrowCenter
        val tip = Path().apply {
            when (side) {
                PopupSide.Bottom -> {
                    moveTo(center - arrow, arrow + 1f)
                    lineTo(center, 0f)
                    lineTo(center + arrow, arrow + 1f)
                }
                PopupSide.Top -> {
                    moveTo(center - arrow, size.height - arrow - 1f)
                    lineTo(center, size.height)
                    lineTo(center + arrow, size.height - arrow - 1f)
                }
                PopupSide.Right -> {
                    moveTo(arrow + 1f, center - arrow)
                    lineTo(0f, center)
                    lineTo(arrow + 1f, center + arrow)
                }
                PopupSide.Left -> {
                    moveTo(size.width - arrow - 1f, center - arrow)
                    lineTo(size.width, center)
                    lineTo(size.width - arrow - 1f, center + arrow)
                }
            }
            close()
        }
        return Outline.Generic(Path.combine(PathOperation.Union, rect, tip))
    }
}

@Composable
fun TipPopover(
    modifier: Modifier = Modifier,
    side: PopupSide = PopupSide.Bottom,
    align: PopupAlign = PopupAlign.Center,
    sideOffset: Dp = 10.dp,
    content: @Composable () -> Unit,
) {
    val context = tip()
    val transition = remember { MutableTransitionState(false) }
    transition.targetState = context.visible
    val anchor = context.anchor
    if (anchor == null || (!transition.currentState && !transition.targetState)) return
    val density = LocalDensity.current
    val colors = EsuissTheme.colors
    val reduced = formReducedMotion()
    val placement = remember { TipPlacement() }
    val container = LocalWindowInfo.current.containerSize
    val available = with(density) { container.width.toDp() } - 32.dp
    val width = if (container.width > 0 && available < 320.dp) available else 320.dp
    val provider = remember(anchor, side, align, sideOffset, density) {
        with(density) {
            TipPositionProvider(
                anchor = anchor,
                delegate = AnchoredPositionProvider(
                    side = side,
                    align = align,
                    sideOffset = (sideOffset - TipArrow).roundToPx(),
                    alignOffset = 0,
                    inset = PopupInset.roundToPx(),
                    margin = PopupMargin.roundToPx(),
                    bottomLimit = Int.MAX_VALUE,
                    point = null,
                    placement = PopupPlacement(),
                ),
                inset = PopupInset.roundToPx(),
                arrow = TipArrow.roundToPx(),
                edge = Radius.xl2.roundToPx(),
                requested = side,
                placement = placement,
            )
        }
    }
    val resolved = placement.side
    val vertical = resolved == PopupSide.Top || resolved == PopupSide.Bottom
    val shape = TipBubbleShape(resolved, placement.arrow, Radius.xl2)
    Popup(
        popupPositionProvider = provider,
        onDismissRequest = { if (context.visible) context.dismiss() },
        properties = PopupProperties(focusable = false, dismissOnBackPress = true, dismissOnClickOutside = true),
    ) {
        Box(modifier = Modifier.padding(PopupInset)) {
            Box(modifier = Modifier.width(width)) {
                val origin = with(density) {
                    val arrowFraction = placement.arrow / (if (vertical) width.toPx() else 1f).coerceAtLeast(1f)
                    when (resolved) {
                        PopupSide.Bottom -> TransformOrigin(arrowFraction, 0f)
                        PopupSide.Top -> TransformOrigin(arrowFraction, 1f)
                        PopupSide.Right -> TransformOrigin(0f, 0.5f)
                        PopupSide.Left -> TransformOrigin(1f, 0.5f)
                    }
                }
                AnimatedVisibility(
                    visibleState = transition,
                    enter = if (reduced) {
                        fadeIn(tween(0))
                    } else {
                        fadeIn(tween(300, easing = FormEasing)) +
                            scaleIn(tween(300, easing = FormEasing), initialScale = 0.9f, transformOrigin = origin)
                    },
                    exit = if (reduced) {
                        fadeOut(tween(0))
                    } else {
                        fadeOut(tween(300, easing = FormEasing)) +
                            scaleOut(tween(300, easing = FormEasing), targetScale = 0.9f, transformOrigin = origin)
                    },
                ) {
                    TipProvider(context) {
                        TipSurface(
                            modifier = modifier
                                .fillMaxWidth()
                                .shadow(TipShadow.copy(color = colors.label.copy(alpha = 0.14f)), shape)
                                .background(colors.surfaceRaised, shape)
                                .border(0.5.dp, colors.separatorStrong, shape)
                                .padding(
                                    top = if (resolved == PopupSide.Bottom) TipArrow else 0.dp,
                                    bottom = if (resolved == PopupSide.Top) TipArrow else 0.dp,
                                    start = if (resolved == PopupSide.Right) TipArrow else 0.dp,
                                    end = if (resolved == PopupSide.Left) TipArrow else 0.dp,
                                ),
                            content = content,
                        )
                    }
                }
            }
        }
    }
}

@Composable
fun TipIcon(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit,
) {
    Box(modifier = modifier) {
        CompositionLocalProvider(
            LocalContentColor provides EsuissTheme.colors.accent,
            LocalIconSize provides 32.dp,
            content = content,
        )
    }
}

@Composable
fun TipContent(
    modifier: Modifier = Modifier,
    content: @Composable ColumnScope.() -> Unit,
) {
    Column(
        modifier = modifier.layoutId(TipContentSlot),
        verticalArrangement = Arrangement.spacedBy(2.dp),
        content = content,
    )
}

@Composable
fun TipTitle(text: String, modifier: Modifier = Modifier) {
    BasicText(
        text,
        modifier = modifier,
        style = Typography.base.copy(color = EsuissTheme.colors.label, fontWeight = FontWeight.SemiBold),
    )
}

@Composable
fun TipDescription(text: String, modifier: Modifier = Modifier) {
    BasicText(
        text,
        modifier = modifier,
        style = Typography.sm.copy(color = EsuissTheme.colors.labelSecondary),
    )
}

@Composable
fun TipClose(
    modifier: Modifier = Modifier,
    contentDescription: String = "Dismiss tip",
) {
    val context = tip()
    val colors = EsuissTheme.colors
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    Box(
        modifier = modifier
            .layoutId(TipCloseSlot)
            .size(32.dp)
            .background(if (pressed) colors.itemPressed else Color.Transparent, CircleShape)
            .clickable(interactionSource = interaction, indication = null, role = Role.Button) { context.dismiss() }
            .semantics { this.contentDescription = contentDescription },
        contentAlignment = androidx.compose.ui.Alignment.Center,
    ) {
        Icon(
            PhosphorIcons.Bold.X,
            contentDescription = null,
            size = 16.dp,
            tint = if (pressed) colors.label else colors.labelSecondary,
        )
    }
}
```

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

import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.esuiss.ui.theme.Colors
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography

enum class ButtonVariant { Default, Outline, Secondary, Tinted, Ghost, Plain, Destructive, Link }

enum class ButtonSize(
    val height: Dp,
    val horizontalPadding: Dp,
    val spacing: Dp,
    val iconSize: Dp,
    val blockRadius: Dp,
    val isIcon: Boolean,
) {
    Default(36.dp, 16.dp, 6.dp, 16.dp, Radius.md, false),
    Xs(24.dp, 11.dp, 4.dp, 12.dp, Radius.sm, false),
    Sm(32.dp, 12.dp, 4.dp, 16.dp, Radius.md, false),
    Lg(44.dp, 22.dp, 6.dp, 16.dp, Radius.lg, false),
    Xl(56.dp, 31.dp, 8.dp, 16.dp, Radius.xl, false),
    Icon(36.dp, 0.dp, 6.dp, 16.dp, Radius.md, true),
    IconXs(24.dp, 0.dp, 4.dp, 12.dp, Radius.sm, true),
    IconSm(32.dp, 0.dp, 4.dp, 16.dp, Radius.md, true),
    IconLg(44.dp, 0.dp, 6.dp, 16.dp, Radius.lg, true),
    IconXl(56.dp, 0.dp, 8.dp, 16.dp, Radius.xl, true),
    ;

    val textStyle: TextStyle
        get() = when (this) {
            Xs, IconXs -> Typography.xs2
            Lg, Xl -> Typography.base
            else -> Typography.sm
        }
}

@Composable
fun Button(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    variant: ButtonVariant = ButtonVariant.Default,
    size: ButtonSize = ButtonSize.Default,
    block: Boolean = false,
    enabled: Boolean = true,
    content: @Composable RowScope.() -> Unit,
) {
    val colors = EsuissTheme.colors
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val foreground = buttonForeground(variant, colors, enabled)
    val group = LocalButtonGroup.current?.takeIf { variant != ButtonVariant.Ghost && variant != ButtonVariant.Plain }
    RegisterButtonGroupOutline(group, variant == ButtonVariant.Outline)
    val shape = group.itemShape(if (block) RoundedCornerShape(size.blockRadius) else CircleShape)
    val sizing = when {
        block -> Modifier.fillMaxWidth().height(size.height)
        size.isIcon -> Modifier.size(size.height)
        else -> Modifier.height(size.height)
    }

    Row(
        modifier = modifier
            .then(sizing)
            .clip(shape)
            .background(buttonBackground(variant, colors, pressed && enabled, enabled), shape)
            .then(
                when {
                    variant != ButtonVariant.Outline -> Modifier
                    group != null -> Modifier.buttonGroupEdge(group, colors.separator)
                    else -> Modifier.border(1.dp, colors.separator, shape)
                },
            )
            .clickable(
                interactionSource = interaction,
                indication = null,
                enabled = enabled,
                role = Role.Button,
                onClick = onClick,
            )
            .padding(horizontal = size.horizontalPadding),
        horizontalArrangement = Arrangement.spacedBy(size.spacing, Alignment.CenterHorizontally),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        CompositionLocalProvider(
            LocalButtonGroup provides null,
            LocalContentStyle provides size.textStyle.copy(
                color = foreground,
                textDecoration = if (variant == ButtonVariant.Link && pressed && enabled) {
                    TextDecoration.Underline
                } else {
                    null
                },
            ),
            LocalContentColor provides foreground,
            LocalIconSize provides size.iconSize,
        ) {
            content()
        }
    }
}

@Composable
fun Button(
    text: String,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    variant: ButtonVariant = ButtonVariant.Default,
    size: ButtonSize = ButtonSize.Default,
    block: Boolean = false,
    enabled: Boolean = true,
) {
    Button(onClick, modifier, variant, size, block, enabled) {
        BasicText(text, style = LocalContentStyle.current, maxLines = 1)
    }
}

private fun buttonForeground(variant: ButtonVariant, colors: Colors, enabled: Boolean): Color =
    when (variant) {
        ButtonVariant.Default -> colors.onAccent
        ButtonVariant.Outline, ButtonVariant.Secondary, ButtonVariant.Ghost ->
            if (enabled) colors.label else colors.labelQuaternary
        ButtonVariant.Tinted, ButtonVariant.Plain ->
            if (enabled) colors.link else colors.link.copy(alpha = 0.4f)
        ButtonVariant.Destructive ->
            if (enabled) colors.danger else colors.danger.copy(alpha = 0.4f)
        ButtonVariant.Link -> if (enabled) colors.link else colors.labelQuaternary
    }

private fun buttonBackground(variant: ButtonVariant, colors: Colors, pressed: Boolean, enabled: Boolean): Color =
    when (variant) {
        ButtonVariant.Default -> when {
            !enabled -> colors.accentDisabled
            pressed -> colors.accentPressed
            else -> colors.accent
        }
        ButtonVariant.Outline -> when {
            pressed -> colors.itemPressed
            colors.isDark -> Color.Transparent
            else -> colors.surface
        }
        ButtonVariant.Secondary -> when {
            !enabled -> colors.controlDisabled
            pressed -> colors.controlPressed
            else -> colors.control
        }
        ButtonVariant.Tinted -> if (pressed) colors.accentSurfacePressed else colors.accentSurface
        ButtonVariant.Ghost -> if (pressed) colors.itemPressed else Color.Transparent
        ButtonVariant.Plain -> if (pressed) colors.itemPressed else Color.Transparent
        ButtonVariant.Destructive -> if (pressed) colors.dangerSurfacePressed else colors.dangerSurface
        ButtonVariant.Link -> Color.Transparent
    }
```

```kt title="kotlin/components/ButtonGroup.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.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
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.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.drawOutline
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Constraints
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.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography

enum class ButtonGroupOrientation { Horizontal, Vertical }

@Stable
internal class ButtonGroupState(val orientation: ButtonGroupOrientation) {
    var nested by mutableIntStateOf(0)
    var outlined by mutableIntStateOf(0)
}

internal val LocalButtonGroup = compositionLocalOf<ButtonGroupState?> { null }

internal object ButtonGroupFill

private object ButtonGroupDivider

internal fun ButtonGroupState?.itemShape(default: Shape): Shape = if (this == null) default else RectangleShape

@Immutable
internal class ButtonGroupItem(val shape: Shape, val modifier: Modifier)

@Composable
internal fun rememberButtonGroupItem(group: ButtonGroupState?, default: Shape): ButtonGroupItem {
    var edges by remember { mutableIntStateOf(0) }
    val horizontal = group?.orientation != ButtonGroupOrientation.Vertical
    val rtl = LocalLayoutDirection.current == LayoutDirection.Rtl
    val shape = remember(edges, horizontal) {
        val round = if (horizontal) CornerSize(50) else CornerSize(Radius.xl2)
        val square = CornerSize(0)
        val start = if (edges and 1 != 0) round else square
        val end = if (edges and 2 != 0) round else square
        if (horizontal) {
            RoundedCornerShape(topStart = start, topEnd = end, bottomEnd = end, bottomStart = start)
        } else {
            RoundedCornerShape(topStart = start, topEnd = start, bottomEnd = end, bottomStart = end)
        }
    }
    if (group == null || group.nested > 0) return ButtonGroupItem(default, Modifier)
    return ButtonGroupItem(
        shape = shape,
        modifier = Modifier.onPlaced { coordinates ->
            val parent = coordinates.parentLayoutCoordinates ?: return@onPlaced
            val bounds = parent.localBoundingBoxOf(coordinates, clipBounds = false)
            val first: Boolean
            val last: Boolean
            if (horizontal) {
                val left = bounds.left <= 0.5f
                val right = bounds.right >= parent.size.width - 0.5f
                first = if (rtl) right else left
                last = if (rtl) left else right
            } else {
                first = bounds.top <= 0.5f
                last = bounds.bottom >= parent.size.height - 0.5f
            }
            edges = (if (first) 1 else 0) or (if (last) 2 else 0)
        },
    )
}

internal fun Modifier.buttonGroupEdge(group: ButtonGroupState?, color: Color): Modifier {
    if (group == null) return this
    return drawBehind {
        val stroke = 1.dp.toPx()
        if (group.orientation == ButtonGroupOrientation.Horizontal) {
            val x = if (layoutDirection == LayoutDirection.Rtl) 0f else size.width - stroke
            drawRect(color, Offset(x, 0f), Size(stroke, size.height))
        } else {
            drawRect(color, Offset(0f, size.height - stroke), Size(size.width, stroke))
        }
    }
}

@Composable
internal fun RegisterButtonGroupOutline(group: ButtonGroupState?, outlined: Boolean) {
    DisposableEffect(group, outlined) {
        if (group != null && outlined) group.outlined += 1
        onDispose {
            if (group != null && outlined) group.outlined -= 1
        }
    }
}

@Composable
fun ButtonGroup(
    modifier: Modifier = Modifier,
    orientation: ButtonGroupOrientation = ButtonGroupOrientation.Horizontal,
    content: @Composable () -> Unit,
) {
    val parent = LocalButtonGroup.current
    DisposableEffect(parent) {
        parent?.let { it.nested += 1 }
        onDispose { parent?.let { it.nested -= 1 } }
    }
    val state = remember(orientation) { ButtonGroupState(orientation) }
    val colors = EsuissTheme.colors
    val joined = state.nested == 0
    val horizontal = orientation == ButtonGroupOrientation.Horizontal
    val shape = if (horizontal) CircleShape else RoundedCornerShape(Radius.xl2)
    val outline = colors.separator
    Layout(
        content = {
            CompositionLocalProvider(LocalButtonGroup provides state, content = content)
        },
        modifier = modifier
            .semantics { isTraversalGroup = true }
            .then(
                if (joined) {
                    Modifier
                        .clip(shape)
                        .drawWithContent {
                            drawContent()
                            if (state.outlined > 0) {
                                val stroke = 1.dp.toPx()
                                val inset = stroke / 2
                                val path = shape.createOutline(
                                    Size(size.width - stroke, size.height - stroke),
                                    layoutDirection,
                                    this,
                                )
                                translate(inset, inset) {
                                    drawOutline(path, outline, style = Stroke(stroke))
                                }
                            }
                        }
                } else {
                    Modifier
                },
            ),
    ) { measurables, constraints ->
        val gap = if (joined) 0 else 8.dp.roundToPx()
        val items = measurables.filter { it.layoutId != ButtonGroupDivider }
        if (horizontal) {
            val height = if (constraints.hasFixedHeight) {
                constraints.maxHeight
            } else {
                constraints.constrainHeight(items.maxOfOrNull { it.maxIntrinsicHeight(Constraints.Infinity) } ?: 0)
            }
            val fills = measurables.count { it.layoutId == ButtonGroupFill }
            val placeables = arrayOfNulls<Placeable>(measurables.size)
            var used = 0
            measurables.forEachIndexed { index, measurable ->
                if (measurable.layoutId != ButtonGroupFill) {
                    val placeable = measurable.measure(Constraints(minHeight = height, maxHeight = height))
                    placeables[index] = placeable
                    used += placeable.width
                }
            }
            val gaps = gap * (items.size - 1).coerceAtLeast(0)
            val remaining = if (constraints.hasBoundedWidth) {
                (constraints.maxWidth - used - gaps).coerceAtLeast(0)
            } else {
                0
            }
            measurables.forEachIndexed { index, measurable ->
                if (measurable.layoutId == ButtonGroupFill) {
                    val placeable = if (constraints.hasBoundedWidth) {
                        val width = remaining / fills
                        measurable.measure(Constraints(width, width, height, height))
                    } else {
                        measurable.measure(Constraints(minHeight = height, maxHeight = height))
                    }
                    placeables[index] = placeable
                    used += placeable.width
                }
            }
            val width = constraints.constrainWidth(used + gaps)
            layout(width, height) {
                var x = 0
                measurables.forEachIndexed { index, measurable ->
                    val placeable = placeables[index] ?: return@forEachIndexed
                    val divider = measurable.layoutId == ButtonGroupDivider
                    placeable.placeRelative(x, 0, zIndex = if (divider) 1f else 0f)
                    if (!divider) x += placeable.width + gap
                }
            }
        } else {
            val width = if (constraints.hasFixedWidth) {
                constraints.maxWidth
            } else {
                constraints.constrainWidth(items.maxOfOrNull { it.maxIntrinsicWidth(Constraints.Infinity) } ?: 0)
            }
            val placeables = measurables.map { it.measure(Constraints(minWidth = width, maxWidth = width)) }
            val gaps = gap * (items.size - 1).coerceAtLeast(0)
            val height = constraints.constrainHeight(
                placeables.filterIndexed { index, _ -> measurables[index].layoutId != ButtonGroupDivider }.sumOf { it.height } + gaps,
            )
            layout(width, height) {
                var y = 0
                measurables.forEachIndexed { index, measurable ->
                    val divider = measurable.layoutId == ButtonGroupDivider
                    placeables[index].placeRelative(0, y, zIndex = if (divider) 1f else 0f)
                    if (!divider) y += placeables[index].height + gap
                }
            }
        }
    }
}

@Composable
fun ButtonGroupText(
    modifier: Modifier = Modifier,
    content: @Composable RowScope.() -> Unit,
) {
    val colors = EsuissTheme.colors
    val shape = LocalButtonGroup.current.itemShape(CircleShape)
    Row(
        modifier = modifier
            .clip(shape)
            .background(colors.control, shape)
            .padding(horizontal = 10.dp),
        horizontalArrangement = Arrangement.spacedBy(8.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        CompositionLocalProvider(
            LocalButtonGroup provides null,
            LocalContentStyle provides Typography.sm.copy(color = colors.label),
            LocalContentColor provides colors.label,
            LocalIconSize provides 16.dp,
        ) {
            content()
        }
    }
}

@Composable
fun ButtonGroupSeparator(
    modifier: Modifier = Modifier,
    orientation: Orientation = Orientation.Vertical,
    color: Color = Color.Unspecified,
) {
    val line = color.takeOrElse { EsuissTheme.colors.label.copy(alpha = 0.2f) }
    Box(
        modifier = modifier
            .layoutId(ButtonGroupDivider)
            .then(
                if (orientation == Orientation.Vertical) {
                    Modifier.layout { measurable, constraints ->
                        val placeable = measurable.measure(constraints.copy(minWidth = 0, maxWidth = 0))
                        layout(0, placeable.height) { placeable.place(0, 0) }
                    }
                } else {
                    Modifier.layout { measurable, constraints ->
                        val placeable = measurable.measure(constraints.copy(minHeight = 0, maxHeight = 0))
                        layout(placeable.width, 0) { placeable.place(0, 0) }
                    }
                },
            )
            .drawBehind {
                val stroke = 1.dp.toPx()
                val inset = 9.dp.toPx()
                if (orientation == Orientation.Vertical) {
                    val x = if (layoutDirection == LayoutDirection.Rtl) -stroke else 0f
                    drawRect(line, Offset(x, inset), Size(stroke, (size.height - inset * 2).coerceAtLeast(0f)))
                } else {
                    drawRect(line, Offset(inset, 0f), Size((size.width - inset * 2).coerceAtLeast(0f), stroke))
                }
            },
    )
}
```

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

import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.Dp
import com.esuiss.ui.theme.EsuissTheme

@Composable
fun Icon(
    imageVector: ImageVector,
    contentDescription: String?,
    modifier: Modifier = Modifier,
    size: Dp = LocalIconSize.current,
    tint: Color = LocalContentColor.current.takeOrElse { EsuissTheme.colors.label },
) {
    Image(
        imageVector = imageVector,
        contentDescription = contentDescription,
        modifier = modifier.size(size),
        colorFilter = ColorFilter.tint(tint),
    )
}
```

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

import android.os.Build
import android.view.Window
import android.view.WindowManager
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.EnterExitState
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideIn
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
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.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog as WindowDialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.X
import com.esuiss.ui.theme.Colors
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Shadow
import com.esuiss.ui.theme.Shadows
import com.esuiss.ui.theme.Typography
import com.esuiss.ui.theme.shadow
import kotlin.math.max
import kotlin.math.min

enum class PopupSide { Top, Bottom, Left, Right }

enum class PopupAlign { Start, Center, End }

internal val PopupInset = 16.dp

internal val PopupMargin = 8.dp

@Stable
internal class PopupPlacement {
    var anchorWidth by mutableIntStateOf(0)
}

internal class AnchoredPositionProvider(
    private val side: PopupSide,
    private val align: PopupAlign,
    private val sideOffset: Int,
    private val alignOffset: Int,
    private val inset: Int,
    private val margin: Int,
    private val bottomLimit: Int,
    private val point: IntOffset?,
    private val placement: PopupPlacement,
) : PopupPositionProvider {
    override fun calculatePosition(
        anchorBounds: IntRect,
        windowSize: IntSize,
        layoutDirection: LayoutDirection,
        popupContentSize: IntSize,
    ): IntOffset {
        val anchor = if (point != null) {
            val x = anchorBounds.left + point.x
            val y = anchorBounds.top + point.y
            IntRect(x, y, x, y)
        } else {
            anchorBounds
        }
        val width = (popupContentSize.width - inset * 2).coerceAtLeast(0)
        val height = (popupContentSize.height - inset * 2).coerceAtLeast(0)
        val left = margin
        val top = margin
        val right = windowSize.width - margin
        val bottom = min(windowSize.height, bottomLimit) - margin

        fun space(candidate: PopupSide): Int = when (candidate) {
            PopupSide.Bottom -> bottom - anchor.bottom - sideOffset
            PopupSide.Top -> anchor.top - sideOffset - top
            PopupSide.Right -> right - anchor.right - sideOffset
            PopupSide.Left -> anchor.left - sideOffset - left
        }

        val opposite = when (side) {
            PopupSide.Bottom -> PopupSide.Top
            PopupSide.Top -> PopupSide.Bottom
            PopupSide.Left -> PopupSide.Right
            PopupSide.Right -> PopupSide.Left
        }
        val vertical = side == PopupSide.Top || side == PopupSide.Bottom
        val needed = if (vertical) height else width
        val resolved = if (space(side) >= needed || space(side) >= space(opposite)) side else opposite
        val rtl = layoutDirection == LayoutDirection.Rtl

        var x: Int
        var y: Int
        if (vertical) {
            x = when (align) {
                PopupAlign.Start -> if (rtl) anchor.right - width else anchor.left
                PopupAlign.Center -> anchor.left + (anchor.width - width) / 2
                PopupAlign.End -> if (rtl) anchor.left else anchor.right - width
            } + if (rtl) -alignOffset else alignOffset
            y = if (resolved == PopupSide.Bottom) anchor.bottom + sideOffset else anchor.top - sideOffset - height
        } else {
            y = when (align) {
                PopupAlign.Start -> anchor.top
                PopupAlign.Center -> anchor.top + (anchor.height - height) / 2
                PopupAlign.End -> anchor.bottom - height
            } + alignOffset
            x = if (resolved == PopupSide.Right) anchor.right + sideOffset else anchor.left - sideOffset - width
        }
        x = x.coerceIn(left, max(left, right - width))
        y = y.coerceIn(top, max(top, bottom - height))
        placement.anchorWidth = anchorBounds.width
        return IntOffset(x - inset, y - inset)
    }
}

private fun transformOrigin(side: PopupSide): TransformOrigin = when (side) {
    PopupSide.Bottom -> TransformOrigin(0.5f, 0f)
    PopupSide.Top -> TransformOrigin(0.5f, 1f)
    PopupSide.Left -> TransformOrigin(1f, 0.5f)
    PopupSide.Right -> TransformOrigin(0f, 0.5f)
}

@Composable
internal fun AnchoredPopup(
    expanded: Boolean,
    onDismissRequest: () -> Unit,
    side: PopupSide = PopupSide.Bottom,
    align: PopupAlign = PopupAlign.Center,
    sideOffset: Dp = 4.dp,
    alignOffset: Dp = 0.dp,
    focusable: Boolean = true,
    point: IntOffset? = null,
    enterMillis: Int = 150,
    exitMillis: Int = 100,
    content: @Composable (PopupPlacement) -> Unit,
) {
    val transition = remember { MutableTransitionState(false) }
    transition.targetState = expanded
    if (!transition.currentState && !transition.targetState) return

    val density = LocalDensity.current
    val imeBottom = WindowInsets.ime.getBottom(density)
    val containerHeight = LocalWindowInfo.current.containerSize.height
    val bottomLimit = if (containerHeight > 0) containerHeight - imeBottom else Int.MAX_VALUE
    val placement = remember { PopupPlacement() }
    val provider = remember(side, align, sideOffset, alignOffset, point, bottomLimit, density) {
        with(density) {
            AnchoredPositionProvider(
                side = side,
                align = align,
                sideOffset = sideOffset.roundToPx(),
                alignOffset = alignOffset.roundToPx(),
                inset = PopupInset.roundToPx(),
                margin = PopupMargin.roundToPx(),
                bottomLimit = bottomLimit,
                point = point,
                placement = placement,
            )
        }
    }
    val currentExpanded by rememberUpdatedState(expanded)
    val dismiss by rememberUpdatedState(onDismissRequest)
    val properties = PopupProperties(
        focusable = focusable,
        dismissOnBackPress = true,
        dismissOnClickOutside = true,
    )
    val slide = with(density) { 8.dp.roundToPx() }
    val origin = transformOrigin(side)
    Popup(
        popupPositionProvider = provider,
        onDismissRequest = { if (currentExpanded) dismiss() },
        properties = properties,
    ) {
        Box(
            modifier = Modifier
                .pointerInput(Unit) {
                    val insetPx = PopupInset.toPx()
                    detectTapGestures { position ->
                        val inside = position.x >= insetPx && position.y >= insetPx &&
                            position.x <= size.width - insetPx && position.y <= size.height - insetPx
                        if (!inside && currentExpanded) dismiss()
                    }
                }
                .padding(PopupInset),
        ) {
            AnimatedVisibility(
                visibleState = transition,
                enter = scaleIn(tween(enterMillis), initialScale = 0.95f, transformOrigin = origin) +
                    slideIn(tween(enterMillis)) {
                        when (side) {
                            PopupSide.Bottom -> IntOffset(0, -slide)
                            PopupSide.Top -> IntOffset(0, slide)
                            PopupSide.Left -> IntOffset(slide, 0)
                            PopupSide.Right -> IntOffset(-slide, 0)
                        }
                    },
                exit = scaleOut(tween(exitMillis), targetScale = 0.95f, transformOrigin = origin),
            ) {
                Box(fadeWithShadow(enterMillis, exitMillis)) {
                    CompositionLocalProvider(LocalButtonGroup provides null) {
                        content(placement)
                    }
                }
            }
        }
    }
}

@Composable
internal fun ModalWindow(
    visible: Boolean,
    onDismissRequest: () -> Unit,
    dismissOnBackPress: Boolean,
    paneTitle: String?,
    content: @Composable () -> Unit,
) {
    if (!visible) return
    WindowDialog(
        onDismissRequest = onDismissRequest,
        properties = DialogProperties(
            dismissOnBackPress = dismissOnBackPress,
            dismissOnClickOutside = false,
            usePlatformDefaultWidth = false,
            decorFitsSystemWindows = false,
            windowTitle = paneTitle.orEmpty(),
        ),
    ) {
        val window = (LocalView.current.parent as? DialogWindowProvider)?.window
        SideEffect {
            window?.apply {
                setDimAmount(0f)
                clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
                setLayout(WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.MATCH_PARENT)
                val params = attributes
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
                    params.fitInsetsTypes = 0
                    params.fitInsetsSides = 0
                    params.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
                } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
                    params.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
                }
                attributes = params
                addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
                clearSystemBarColors()
            }
        }
        CompositionLocalProvider(LocalButtonGroup provides null, content = content)
    }
}

@Suppress("DEPRECATION")
private fun Window.clearSystemBarColors() {
    statusBarColor = android.graphics.Color.TRANSPARENT
    navigationBarColor = android.graphics.Color.TRANSPARENT
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        isStatusBarContrastEnforced = false
        isNavigationBarContrastEnforced = false
    }
}

@Composable
internal fun AnimatedVisibilityScope.fadeWithShadow(enterMillis: Int, exitMillis: Int): Modifier {
    val alpha by transition.animateFloat(
        transitionSpec = { tween(if (targetState == EnterExitState.Visible) enterMillis else exitMillis) },
        label = "alpha",
    ) { if (it == EnterExitState.Visible) 1f else 0f }
    return Modifier.graphicsLayer {
        this.alpha = alpha
        compositingStrategy = CompositingStrategy.ModulateAlpha
    }
}

@Composable
internal fun Scrim(
    transition: MutableTransitionState<Boolean>,
    enterMillis: Int,
    exitMillis: Int,
    onClick: (() -> Unit)?,
) {
    val color = EsuissTheme.colors.scrim
    val click by rememberUpdatedState(onClick)
    AnimatedVisibility(
        visibleState = transition,
        enter = fadeIn(tween(enterMillis)),
        exit = fadeOut(tween(exitMillis)),
    ) {
        Box(
            modifier = Modifier
                .fillMaxSize()
                .background(color)
                .pointerInput(Unit) { detectTapGestures { click?.invoke() } },
        )
    }
}

@Stable
internal class PaneTitleState {
    var value by mutableStateOf<String?>(null)
}

internal val LocalPaneTitle = staticCompositionLocalOf<PaneTitleState?> { null }

@Composable
internal fun RegisterPaneTitle(text: String) {
    val state = LocalPaneTitle.current
    SideEffect { state?.value = text }
}

internal fun Modifier.raisedSurface(colors: Colors, shape: Shape, shadow: Shadow = Shadows.lg): Modifier =
    this
        .shadow(shadow, shape)
        .clip(shape)
        .background(colors.surfaceRaised, shape)
        .border(1.dp, colors.label.copy(alpha = if (colors.isDark) 0.1f else 0.05f), shape)

internal fun titleStyle(colors: Colors, style: TextStyle = Typography.base, centered: Boolean = false): TextStyle =
    style.copy(
        color = colors.label,
        fontWeight = FontWeight.SemiBold,
        textAlign = if (centered) TextAlign.Center else TextAlign.Unspecified,
    )

internal fun descriptionStyle(colors: Colors, centered: Boolean = false): TextStyle =
    Typography.sm.copy(
        color = colors.labelSecondary,
        textAlign = if (centered) TextAlign.Center else TextAlign.Unspecified,
    )

@Composable
internal fun OverlayTitle(text: String, modifier: Modifier, style: TextStyle) {
    RegisterPaneTitle(text)
    BasicText(text, modifier = modifier.semantics { heading() }, style = style)
}

@Composable
internal fun OverlayCloseButton(
    onClick: () -> Unit,
    label: String?,
    modifier: Modifier = Modifier,
) {
    val colors = EsuissTheme.colors
    if (label != null) {
        Button(onClick = onClick, modifier = modifier, variant = ButtonVariant.Plain, size = ButtonSize.Sm) {
            BasicText(label, style = Typography.base.copy(color = LocalContentColor.current), maxLines = 1)
        }
        return
    }
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    Box(
        modifier = modifier
            .size(44.dp)
            .clip(CircleShape)
            .clickable(interactionSource = interaction, indication = null, role = Role.Button, onClick = onClick)
            .semantics { contentDescription = "Close" },
        contentAlignment = Alignment.Center,
    ) {
        Box(
            modifier = Modifier
                .size(32.dp)
                .background(if (pressed) colors.separator else colors.surfaceTertiary, CircleShape),
            contentAlignment = Alignment.Center,
        ) {
            Icon(PhosphorIcons.Regular.X, contentDescription = null, size = 16.dp, tint = colors.label)
        }
    }
}

@Composable
internal fun rememberLongPressModifier(enabled: Boolean, onLongPress: (Offset) -> Unit): Modifier {
    val callback by rememberUpdatedState(onLongPress)
    if (!enabled) return Modifier
    return Modifier.pointerInput(Unit) {
        awaitEachGesture {
            val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
            val released = withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) {
                while (true) {
                    val event = awaitPointerEvent(PointerEventPass.Initial)
                    val change = event.changes.firstOrNull { it.id == down.id }
                    if (change == null || !change.pressed) break
                    if ((change.position - down.position).getDistance() > viewConfiguration.touchSlop) break
                }
                true
            }
            if (released == null) {
                callback(down.position)
                while (true) {
                    val event = awaitPointerEvent(PointerEventPass.Initial)
                    event.changes.forEach { it.consume() }
                    if (event.changes.none { it.pressed }) break
                }
            }
        }
    }
}
```

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

## Usage

```tsx
import { TipGroup, Tip, TipCard, TipAnchor, TipPopover, TipIcon, TipContent, TipTitle, TipDescription, TipClose } from "@/components/interactions/tip"
```

```tsx
<TipsExample fresh />
```

Give each `Tip` a stable `id`. Compose `TipIcon`, `TipContent` with `TipTitle` and `TipDescription`, and `TipClose` inside a `TipCard` or `TipPopover`. For a popover, wrap the target in `TipAnchor` inside the same `Tip`.

By default dismissals are kept in memory and reset when the app restarts. To persist them, pass a `storage` adapter to `TipGroup` with `getItem` and `setItem`, sync or async, such as one backed by your app's key-value store. Keys are prefixed with `tip:`. `resetTips(ids, storage)` clears dismissals, and `useTip` returns `visible` and `dismiss`. The `TipStorage` type is exported.

## Examples

### Card

An inline card tip with an icon, title, description and close button.

![Preview](https://uim.suiss.app/previews/interactions-tip--card.light.webp)

```tsx
import { PushPinIcon } from "phosphor-react-native"
import * as React from "react"
import { resetTips, Tip, TipCard, TipClose, TipContent, TipDescription, TipIcon, TipTitle } from "@/components/interactions/tip"
import { Icon } from "@/components/ui/icon"

function CardExample() {
  const [ready, setReady] = React.useState(false)

  React.useEffect(() => {
    resetTips(["story-card"]).then(() => setReady(true))
  }, [])

  if (!ready) return null

  return (
    <Tip id="story-card">
      <TipCard>
        <TipIcon>
          <Icon as={PushPinIcon} weight="duotone" />
        </TipIcon>
        <TipContent>
          <TipTitle>Pin your favorites</TipTitle>
          <TipDescription>
            Swipe right on a note to keep it at the top of the list.
          </TipDescription>
        </TipContent>
        <TipClose />
      </TipCard>
    </Tip>
  )
}

export function TipCardExample() {
  return (
    <CardExample />
  )
}
```

```swift
import EsuissUI
import PhosphorSwift
import SwiftUI

struct TipPinCard: View {
  let id: String

  var body: some View {
    Tip(id: id) {
      TipCard {
        TipIcon { Ph.pushPin.duotone }
        TipContent {
          TipTitle("Pin your favorites")
          TipDescription("Swipe right on a note to keep it at the top of the list.")
        }
        TipClose()
      }
    }
  }
}

struct TipCardExample: View {
  @State private var ready = false

  var body: some View {
    Group {
      if ready {
        TipPinCard(id: "story-card")
      }
    }
    .task {
      await resetTips(["story-card"])
      ready = true
    }
  }
}
```

```kotlin
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Duotone
import com.adamglin.phosphoricons.duotone.PushPin
import com.esuiss.ui.components.Icon
import com.esuiss.ui.interactions.Tip
import com.esuiss.ui.interactions.TipCard
import com.esuiss.ui.interactions.TipClose
import com.esuiss.ui.interactions.TipContent
import com.esuiss.ui.interactions.TipDescription
import com.esuiss.ui.interactions.TipIcon
import com.esuiss.ui.interactions.TipTitle
import com.esuiss.ui.interactions.resetTips

@Composable
fun TipPinCard() {
    TipCard {
        TipIcon {
            Icon(PhosphorIcons.Duotone.PushPin, contentDescription = null)
        }
        TipContent {
            TipTitle("Pin your favorites")
            TipDescription("Swipe right on a note to keep it at the top of the list.")
        }
        TipClose()
    }
}

@Composable
fun TipCardExample() {
    var ready by remember { mutableStateOf(false) }
    LaunchedEffect(Unit) {
        resetTips(listOf("story-card"))
        ready = true
    }
    if (!ready) return
    Tip(id = "story-card") {
        TipPinCard()
    }
}
```

## API Reference

### TipGroup

Shows its tips one at a time and supplies the storage.

### Tip

One hint, identified by `id`.

### TipCard

Renders the tip inline as a card.

### TipAnchor

The element the popover points at.

### TipPopover

Renders the tip as a popover next to the anchor.

### TipIcon

A decorative icon slot.

### TipContent

Holds the title and description.

### TipTitle

The tip's heading.

### TipDescription

The tip's supporting text.

### TipClose

A button that dismisses the tip.

## Accessibility

- `TipCard` has the `note` role.
- `TipPopover` is non-modal, does not move focus, and is labelled by `TipTitle`.
- `TipClose` is labelled `Dismiss tip`.
- The icon is hidden from screen readers.

## Notes

- Cards use `bg-surface-secondary`; popovers use `bg-surface-raised` with a border that is stronger in dark mode, and are up to 320 points wide.
- Animations respect the system reduce motion setting.
- On iOS the popover renders in a full-window overlay so it appears above native screens.
