# Tip

A dismissible feature tip shown inline or as a popover, remembered once dismissed and sequenced in groups.

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

Source: https://ui.suiss.app/docs/interactions/tip

**React**

**Example**

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

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>
      <div className="mx-auto flex w-[min(28rem,calc(100vw-2rem))] flex-col gap-4">
        <div className="flex items-center justify-between">
          <h2 className="text-3xl font-bold tracking-tight">Notes</h2>
          <div className="flex items-center">
            <Button
              variant="plain"
              size="icon-lg"
              aria-label="Share folder"
              onClick={() => setLog("Shared the folder")}
            >
              <ShareNetworkIcon className="size-5.5" />
            </Button>
            <Tip id="notes-compose" delay={600}>
              <TipAnchor>
                <Button
                  variant="plain"
                  size="icon-lg"
                  aria-label="New note"
                  onClick={() => setLog("Started a new note")}
                >
                  <NotePencilIcon className="size-5.5" />
                </Button>
              </TipAnchor>
              <TipPopover side="bottom" align="end">
                <TipIcon>
                  <NotePencilIcon />
                </TipIcon>
                <TipContent>
                  <TipTitle>Start a note fast</TipTitle>
                  <TipDescription>
                    Tap here any time to begin a new note.
                  </TipDescription>
                </TipContent>
                <TipClose />
              </TipPopover>
            </Tip>
          </div>
        </div>
        <Tip id="notes-pin">
          <TipCard>
            <TipIcon>
              <PushPinIcon />
            </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>
        <ul className="overflow-hidden rounded-2xl border">
          {notes.map((note) => (
            <li key={note.id} className="not-first:border-t">
              <button
                type="button"
                onClick={() => setLog(`Opened ${note.title}`)}
                className="flex w-full flex-col px-4 py-3 text-start outline-none focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:ring-inset"
              >
                <span className="truncate font-semibold">{note.title}</span>
                <span className="truncate text-sm text-muted-foreground">
                  {note.preview}
                </span>
              </button>
            </li>
          ))}
        </ul>
        <p role="status" className="min-h-5 text-sm text-muted-foreground">
          {log}
        </p>
      </div>
    </TipGroup>
  )
}

function TipsExample() {
  const [key, setKey] = React.useState(0)

  return (
    <div className="flex flex-col gap-6">
      <NotesPage key={key} />
      <Button
        variant="secondary"
        size="sm"
        className="self-center"
        onClick={() => {
          resetTips(TIP_IDS)
          setKey((value) => value + 1)
        }}
      >
        Show tips again
      </Button>
    </div>
  )
}

export default function TipDemo() {
  return <TipsExample />
}
```

`Tip` recreates TipKit-style feature tips. Each tip has an `id`; once dismissed, the id is stored in `localStorage` and the tip no longer appears. Show a tip inline with `TipCard`, which expands into the layout, or attach it to a control with `TipAnchor` and `TipPopover`, which uses a non-modal popover with an arrow. Using the anchored control also dismisses its tip.

Wrap several tips in `TipGroup` to show them one at a time, in the order they mount; when one is dismissed, the next appears. Use tips sparingly to point out features people might otherwise miss.

## Installation

### CLI

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

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

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

### Manual

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

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

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

```tsx title="components/interactions/tip.tsx"
"use client"

import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import { XIcon } from "@phosphor-icons/react"
import * as React from "react"
import { cn } from "@/lib/utils"

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

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

type TipGroupContextProps = {
  current: string | null
  register: (id: string) => () => void
  complete: (id: string) => void
}

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

function isDismissed(id: string) {
  try {
    return localStorage.getItem(STORAGE_PREFIX + id) === "dismissed"
  } catch {
    return false
  }
}

function remember(id: string) {
  try {
    localStorage.setItem(STORAGE_PREFIX + id, "dismissed")
    return true
  } catch {
    return false
  }
}

function resetTips(ids: string[]) {
  try {
    for (const id of ids) localStorage.removeItem(STORAGE_PREFIX + id)
    return true
  } catch {
    return false
  }
}

function TipGroup({ children }: { children?: React.ReactNode }) {
  const [order, setOrder] = React.useState<string[]>([])
  const [completed, setCompleted] = React.useState<string[]>([])
  const current =
    order.find((id) => !completed.includes(id) && !isDismissed(id)) ?? null

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

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

  const contextValue = React.useMemo(
    () => ({ current, register, complete }),
    [current, register, 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 [ready, setReady] = React.useState(false)
  const [dismissed, setDismissed] = React.useState(false)
  const anchorRef = React.useRef<HTMLSpanElement>(null)
  const titleId = React.useId()
  const descriptionId = React.useId()
  const register = group?.register
  const complete = group?.complete
  const turn = !group || group.current === id
  const visible = ready && turn && !dismissed

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

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

  const dismiss = React.useCallback(() => {
    setDismissed(true)
    remember(id)
    complete?.(id)
    onDismiss?.()
  }, [id, complete, onDismiss])

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

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

function TipCard({
  className,
  children,
  ...props
}: React.ComponentProps<"div">) {
  const { visible } = useTip()
  const [mounted, setMounted] = React.useState(visible)

  React.useEffect(() => {
    if (visible) setMounted(true)
    else if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      setMounted(false)
    }
  }, [visible])

  if (!mounted) return null

  return (
    <div
      data-slot="tip-card-frame"
      data-closed={visible ? undefined : ""}
      inert={!visible}
      onTransitionEnd={(event) => {
        if (!visible && event.target === event.currentTarget) setMounted(false)
      }}
      className="grid grid-rows-[1fr] transition-[grid-template-rows,opacity,translate] duration-400 ease-[cubic-bezier(0.32,0.72,0,1)] starting:grid-rows-[0fr] starting:opacity-0 data-closed:grid-rows-[0fr] data-closed:opacity-0 data-closed:duration-300 motion-safe:starting:-translate-y-2 motion-reduce:transition-none"
    >
      <div className="min-h-0 overflow-hidden">
        <div
          role="note"
          data-slot="tip-card"
          className={cn(
            "relative flex items-start gap-3 rounded-2xl bg-muted p-4 pe-12",
            className
          )}
          {...props}
        >
          {children}
        </div>
      </div>
    </div>
  )
}

function TipAnchor({
  className,
  ref,
  onClickCapture,
  ...props
}: React.ComponentProps<"span">) {
  const { anchorRef, visible, dismiss } = useTip()

  return (
    <span
      ref={(node) => {
        anchorRef.current = node
        if (typeof ref === "function") return ref(node)
        if (ref) ref.current = node
      }}
      data-slot="tip-anchor"
      onClickCapture={(event) => {
        onClickCapture?.(event)
        if (visible) dismiss()
      }}
      className={cn("inline-flex", className)}
      {...props}
    />
  )
}

function TipPopover({
  className,
  children,
  side = "bottom",
  align = "center",
  sideOffset = 10,
  ...props
}: PopoverPrimitive.Popup.Props &
  Pick<PopoverPrimitive.Positioner.Props, "side" | "align" | "sideOffset">) {
  const { visible, dismiss, anchorRef, titleId, descriptionId } = useTip()

  return (
    <PopoverPrimitive.Root
      open={visible}
      modal={false}
      onOpenChange={(open) => {
        if (!open) dismiss()
      }}
    >
      <PopoverPrimitive.Portal>
        <PopoverPrimitive.Positioner
          anchor={anchorRef}
          side={side}
          align={align}
          sideOffset={sideOffset}
          collisionPadding={16}
          className="isolate z-50"
        >
          <PopoverPrimitive.Popup
            data-slot="tip-popover"
            aria-labelledby={titleId}
            aria-describedby={descriptionId}
            initialFocus={false}
            finalFocus={false}
            className={cn(
              "relative flex w-[min(20rem,calc(100vw-2rem))] origin-(--transform-origin) items-start gap-3 rounded-2xl bg-popover p-4 pe-12 text-popover-foreground [filter:drop-shadow(0_0_0.5px_var(--color-input))_drop-shadow(0_12px_24px_color-mix(in_oklab,var(--color-foreground)_14%,transparent))] dark:[filter:drop-shadow(0_0_0.5px_var(--color-border))_drop-shadow(0_12px_24px_rgb(0_0_0/0.4))] outline-none transition-[opacity,scale] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] data-ending-style:scale-90 data-ending-style:opacity-0 data-starting-style:scale-90 data-starting-style:opacity-0 motion-reduce:transition-none",
              className
            )}
            {...props}
          >
            <PopoverPrimitive.Arrow className="size-4 data-[side=bottom]:-top-2 data-[side=left]:-right-2 data-[side=right]:-left-2 data-[side=top]:-bottom-2">
              <span className="block size-3 translate-x-0.5 translate-y-0.5 rotate-45 rounded-xs bg-popover" />
            </PopoverPrimitive.Arrow>
            {children}
          </PopoverPrimitive.Popup>
        </PopoverPrimitive.Positioner>
      </PopoverPrimitive.Portal>
    </PopoverPrimitive.Root>
  )
}

function TipIcon({ className, ...props }: React.ComponentProps<"span">) {
  return (
    <span
      aria-hidden
      data-slot="tip-icon"
      className={cn(
        "flex shrink-0 text-primary [&_svg:not([class*='size-'])]:size-8",
        className
      )}
      {...props}
    />
  )
}

function TipContent({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="tip-content"
      className={cn("flex min-w-0 flex-1 flex-col gap-0.5", className)}
      {...props}
    />
  )
}

function TipTitle({ className, ...props }: React.ComponentProps<"p">) {
  const { titleId } = useTip()

  return (
    <p
      id={titleId}
      data-slot="tip-title"
      className={cn("text-base font-semibold", className)}
      {...props}
    />
  )
}

function TipDescription({ className, ...props }: React.ComponentProps<"p">) {
  const { descriptionId } = useTip()

  return (
    <p
      id={descriptionId}
      data-slot="tip-description"
      className={cn("text-sm text-muted-foreground", className)}
      {...props}
    />
  )
}

function TipClose({
  className,
  ...props
}: Omit<React.ComponentProps<"button">, "children">) {
  const { dismiss } = useTip()

  return (
    <button
      type="button"
      data-slot="tip-close"
      aria-label="Dismiss tip"
      onClick={dismiss}
      className={cn(
        "absolute inset-e-2 top-2 flex size-8 items-center justify-center rounded-full text-muted-foreground outline-none after:absolute after:-inset-1.5 after:content-[''] hover:bg-accent hover:text-accent-foreground focus-visible:ring-3 focus-visible:ring-ring/50",
        className
      )}
      {...props}
    >
      <XIcon className="size-4" />
    </button>
  )
}

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

3. Update the import paths to match your project.

## Usage

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

```tsx
<TipsExample />
```

- Give every `Tip` a stable, unique `id`. Place either a `TipCard`, or a `TipAnchor` with a `TipPopover`, inside it.
- Build the content with `TipIcon`, `TipContent`, `TipTitle`, `TipDescription`, and `TipClose`.
- `TipPopover` accepts `side`, `align`, and `sideOffset` for positioning, defaulting to `bottom`, `center`, and `10`.
- Call `resetTips` with a list of ids to clear their dismissed state, for example from a Show tips again control.

## API Reference

### TipGroup

Shows the tips inside it one at a time, in order.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `React.ReactNode` | - | The tips to show in sequence. |

### Tip

Holds a single tip's visibility and dismissed state.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string` | Required | The unique key used to remember that the tip was dismissed. |
| `delay` | `number` | `0` | Milliseconds to wait before showing the tip. |
| `onDismiss` | `() => void` | - | Called when the tip is dismissed. |
| `children` | `React.ReactNode` | - | The card or anchored popover for the tip. |

### TipCard

An inline card that expands into the layout while the tip is visible.

Also accepts the props of `React.ComponentProps<"div">`.

### TipAnchor

Wraps the control that a popover tip points to.

Also accepts the props of `React.ComponentProps<"span">`.

### TipPopover

A popover with an arrow, positioned against `TipAnchor`.

Also accepts the props of the primitive parts it wraps.

### TipIcon

The leading icon of the tip.

Also accepts the props of `React.ComponentProps<"span">`.

### TipContent

The container for the title and description.

Also accepts the props of `React.ComponentProps<"div">`.

### TipTitle

The tip's title.

Also accepts the props of `React.ComponentProps<"p">`.

### TipDescription

The tip's supporting text.

Also accepts the props of `React.ComponentProps<"p">`.

### TipClose

The button that dismisses the tip.

Also accepts the props of `React.ComponentProps<"button">`.

## Accessibility

- `TipCard` renders with `role="note"`.
- `TipPopover` is labelled by `TipTitle` and described by `TipDescription`. It does not move focus when it opens or closes, and pressing `Escape` or outside dismisses it.
- `TipClose` is labelled `Dismiss tip`. `TipIcon` is `aria-hidden`.

## Notes

- Dismissed tips are stored under `tip:` followed by the id in `localStorage`. When storage is unavailable, tips still dismiss for the current session.
- Parts use `data-slot` values `tip-card-frame`, `tip-card`, `tip-anchor`, `tip-popover`, `tip-icon`, `tip-content`, `tip-title`, `tip-description`, and `tip-close`.

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.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-border">
          {notes.map((note, index) => (
            <Pressable
              key={note.id}
              onPress={() => setLog(`Opened ${note.title}`)}
              className={
                index > 0
                  ? "border-t border-border px-4 py-3 active:opacity-80"
                  : "px-4 py-3 active:opacity-80"
              }
            >
              <Text numberOfLines={1} className="font-semibold">
                {note.title}
              </Text>
              <Text numberOfLines={1} className="text-sm text-muted-foreground">
                {note.preview}
              </Text>
            </Pressable>
          ))}
        </View>
        <Text role="status" className="min-h-5 text-sm text-muted-foreground">
          {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(EsuissColor.foreground)
                  .lineLimit(1)
                Text(verbatim: note.preview)
                  .textStyle(.sm)
                  .foregroundStyle(EsuissColor.mutedForeground)
                  .lineLimit(1)
              }
              .padding(.horizontal, 16)
              .padding(.vertical, 12)
              .frame(maxWidth: .infinity, alignment: .leading)
              .contentShape(Rectangle())
            }
            .buttonStyle(TipRowStyle())
            .overlay(alignment: .top) {
              if index > 0 {
                EsuissColor.border.frame(height: 1)
              }
            }
          }
        }
        .clipShape(shape)
        .overlay { shape.strokeBorder(EsuissColor.border) }
        Text(verbatim: log)
          .textStyle(.sm)
          .foregroundStyle(EsuissColor.mutedForeground)
          .frame(minHeight: 20)
          .accessibilityAddTraits(.updatesFrequently)
      }
    }
  }
}

struct TipRowStyle: ButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    configuration.label
      .opacity(configuration.isPressed ? 0.8 : 1)
  }
}

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.alpha
import androidx.compose.ui.draw.clip
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.border, shape),
            ) {
                tipNotes.forEachIndexed { index, note ->
                    if (index > 0) Box(Modifier.fillMaxWidth().height(1.dp).background(colors.border))
                    TipNoteRow(note) { log = "Opened ${note.title}" }
                }
            }
            Text(
                log,
                modifier = Modifier.heightIn(min = 20.dp).semantics { liveRegion = LiveRegionMode.Polite },
                style = Typography.sm.copy(color = colors.mutedForeground),
            )
        }
    }
}

@Composable
fun TipNoteRow(note: TipNote, onClick: () -> Unit) {
    val colors = EsuissTheme.colors
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    Column(
        Modifier
            .fillMaxWidth()
            .alpha(if (pressed) 0.8f else 1f)
            .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.mutedForeground),
            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

**React Native**

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

See [Installation](https://ui.suiss.app/docs/installation) for the `components.json` setup. Or use the suiss/ui CLI:

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

**SwiftUI**

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

**Jetpack Compose**

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

The CLI also adds what it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text). On React Native it also adds the animated view `components/ui/animated-view.tsx`.

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

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

2. Add the pieces it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/popover.tsx`, `components/primitives/types.tsx`, `components/primitives/use-render.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

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

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

4. Copy the source into `components/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<React.ComponentRef<typeof 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<React.ComponentRef<typeof 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-muted p-4 pe-12",
          className
        )}
        {...props}
      />
    </AnimatedView>
  )
}

function TipAnchor({
  className,
  onStartShouldSetResponderCapture,
  ...props
}: ViewProps) {
  const { anchorRef, visible, dismiss } = useTip()
  return (
    <View
      ref={anchorRef}
      collapsable={false}
      onStartShouldSetResponderCapture={(event) => {
        if (visible) dismiss()
        return onStartShouldSetResponderCapture?.(event) ?? 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-foreground/5 bg-popover p-4 pe-12 shadow-lg dark:border-foreground/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-popover" />
              </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-primary">
      <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-muted-foreground", 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:opacity-80",
        className
      )}
      {...props}
    >
      <Icon as={XIcon} weight="bold" className="size-4 text-muted-foreground" />
    </Pressable>
  )
}

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

5. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

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

2. Add the pieces it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/popover.tsx`, `components/primitives/types.tsx`, `components/primitives/use-render.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

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

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

4. Copy the source into `components/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<React.ComponentRef<typeof 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<React.ComponentRef<typeof 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-muted p-4 pe-12",
          className
        )}
        {...props}
      />
    </AnimatedView>
  )
}

function TipAnchor({
  className,
  onStartShouldSetResponderCapture,
  ...props
}: ViewProps) {
  const { anchorRef, visible, dismiss } = useTip()
  return (
    <View
      ref={anchorRef}
      collapsable={false}
      onStartShouldSetResponderCapture={(event) => {
        if (visible) dismiss()
        return onStartShouldSetResponderCapture?.(event) ?? 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-foreground-5 bg-popover p-4 pe-12 shadow-lg dark:border-foreground-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-popover" />
              </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-primary">
      <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-muted-foreground", 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:opacity-80",
        className
      )}
      {...props}
    >
      <Icon as={XIcon} weight="bold" className="size-4 text-muted-foreground" />
    </Pressable>
  )
}

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

5. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

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

2. Add the pieces it builds on: [Icon](https://ui.suiss.app/docs/components/icon), [Text](https://ui.suiss.app/docs/components/text), and the primitives `components/primitives/popover.tsx`, `components/primitives/types.tsx`, `components/primitives/use-render.tsx`, and the animated view `components/ui/animated-view.tsx` from the next step.

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

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

4. Copy the source into `components/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<React.ComponentRef<typeof 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<React.ComponentRef<typeof 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.muted }, style]}
        {...props}
      />
    </AnimatedView>
  )
}

function TipAnchor({
  style,
  onStartShouldSetResponderCapture,
  ...props
}: ViewProps) {
  const { anchorRef, visible, dismiss } = useTip()
  return (
    <View
      ref={anchorRef}
      collapsable={false}
      onStartShouldSetResponderCapture={(event) => {
        if (visible) dismiss()
        return onStartShouldSetResponderCapture?.(event) ?? 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.foreground,
                    isDark ? 0.1 : 0.05
                  ),
                  backgroundColor: colors.popover,
                },
                typeof style === "function" ? style(state) : style,
              ]}
              {...props}
            >
              <PopoverPrimitive.Arrow
                style={(state) => [styles.arrow, ARROW_OFFSET[state.side]]}
              >
                <View
                  style={[
                    styles.arrowSquare,
                    { backgroundColor: colors.popover },
                  ]}
                />
              </PopoverPrimitive.Arrow>
              <TextStyleContext.Provider
                value={{ ...fontSize.base, color: colors.foreground }}
              >
                <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.primary }}>
      <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.foreground }, style]}
      {...props}
    />
  )
}

function TipDescription({
  style,
  ...props
}: React.ComponentProps<typeof Text>) {
  const { colors } = useTheme()
  return (
    <Text
      style={[styles.description, { color: colors.mutedForeground }, 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 && { opacity: 0.8 },
        typeof style === "function" ? style(state) : style,
      ]}
      {...props}
    >
      <Icon as={XIcon} weight="bold" size={16} color={colors.mutedForeground} />
    </Pressable>
  )
}

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

5. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

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

3. Copy the theme files every view uses:

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

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

4. Copy the source and every file it uses:

```swift title="swift/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(EsuissColor.foreground)
      .frame(maxWidth: .infinity, alignment: .leading)
      .background(EsuissColor.muted, 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(EsuissColor.popover)
      }
  }
}

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

  var body: some View {
    TipRowLayout {
      content()
    }
    .foregroundStyle(EsuissColor.foreground)
    .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(EsuissColor.primary)
      .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(EsuissColor.foreground)
      .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(EsuissColor.mutedForeground)
      .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(EsuissColor.mutedForeground)
      .frame(width: 32, height: 32)
      .opacity(configuration.isPressed ? 0.8 : 1)
      .contentShape(Circle().inset(by: -6))
  }
}
```

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

5. Add the files to your app target.

**Jetpack Compose**

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

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

2. Copy the theme files every view uses:

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

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

3. Copy the source and every file it uses:

```kt title="kotlin/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.draw.alpha
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.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.muted, 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.foreground),
        LocalContentColor provides colors.foreground,
        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

@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(Shadow(0.dp, 12.dp, 24.dp, colors.foreground.copy(alpha = 0.14f)), shape)
                                .background(colors.popover, shape)
                                .border(0.5.dp, colors.input, 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.primary,
            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.foreground, fontWeight = FontWeight.SemiBold),
    )
}

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

@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)
            .alpha(if (pressed) 0.8f else 1f)
            .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.foreground else colors.mutedForeground,
        )
    }
}
```

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

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

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

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

- `kotlin/components/Icon.kt`: see [Icon](https://ui.suiss.app/docs/components/icon.md)

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

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://ui.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.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `storage` | `TipStorage` | `memoryStorage` | Adapter with `getItem` and `setItem` for remembering dismissals. Defaults to in-memory storage. |
| `children` | `React.ReactNode` | - |  |

### Tip

One hint, identified by `id`.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `id` | `string` | Required | Stable key used to remember the dismissal. |
| `delay` | `number` | `0` | Milliseconds to wait before showing. Defaults to `0`. |
| `onDismiss` | `() => void` | - | Called when the tip is dismissed. |
| `children` | `React.ReactNode` | - |  |

### TipCard

Renders the tip inline as a card.

Also accepts the props of `ViewProps`.

### TipAnchor

The element the popover points at.

Also accepts the props of `ViewProps`.

### TipPopover

Renders the tip as a popover next to the anchor.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `side` | `Side` | `"bottom"` | Side of the anchor to show on. Defaults to `bottom`. |
| `align` | `Align` | `"center"` | Alignment against the anchor. Defaults to `center`. |
| `sideOffset` | `number` | `10` | Gap from the anchor. Defaults to `10`. |
| `portalHost` | `string` | - | Name of a portal host to render into. |

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

### TipIcon

A decorative icon slot.

Also accepts the props of `ViewProps`.

### TipContent

Holds the title and description.

Also accepts the props of `ViewProps`.

### TipTitle

The tip's heading.

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

### TipDescription

The tip's supporting text.

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

### TipClose

A button that dismisses the tip.

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

## 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-muted`; popovers use `bg-popover` 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.

