suiss/ui
  • Docs
  • Components
  • Patterns
  • Interactions
  • Charts
  • Blocks
GitHubGet started
DocsComponentsPatternsInteractionsChartsBlocks
Interactions15
  • Overview
  • Card Stack
  • Image Viewer
  • Island
  • OTP Autofill
  • Preview Menu
  • Pull to Refresh
  • Reorder List
  • Search Reveal
  • Section Index
  • Selection List
  • Stepper
  • Swipe Actions
  • Tip
  • Top Banner
  • Undo Bar

suiss/ui · Interaction

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.

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

$ pnpm dlx shadcn@latest add @esuiss/tip

Works in any project with a components.json. See Installation for the one-line registry setup. Or use the suiss/ui CLI:

$ pnpm dlx @esuiss/ui@latest add interactions tip

Manual

  1. Install the dependencies and add the cn helper:
$ pnpm add @base-ui/react @phosphor-icons/react
  1. Copy the source into components/interactions/tip.tsx:
  1. Update the import paths to match your project.

Usage

import { TipGroup, Tip, TipCard, TipAnchor, TipPopover, TipIcon, TipContent, TipTitle, TipDescription, TipClose } 
<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

01

TipGroup

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

PropTypeDefaultDescription
childrenReact.ReactNode-The tips to show in sequence.
02

Tip

Holds a single tip's visibility and dismissed state.

PropTypeDefaultDescription
idstringRequiredThe unique key used to remember that the tip was dismissed.
delaynumber0Milliseconds to wait before showing the tip.
onDismiss() => void-Called when the tip is dismissed.
childrenReact.ReactNode-The card or anchored popover for the tip.
03

TipCard

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

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

04

TipAnchor

Wraps the control that a popover tip points to.

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

05

TipPopover

A popover with an arrow, positioned against TipAnchor.

Also accepts the props of the primitive parts it wraps.

06

TipIcon

The leading icon of the tip.

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

07

TipContent

The container for the title and description.

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

08

TipTitle

The tip's title.

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

09

TipDescription

The tip's supporting text.

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

10

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.

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

$ pnpm dlx shadcn@latest add @esuiss-rn/tip

See Installation for the components.json setup. Or use the suiss/ui CLI:

$ pnpm dlx @esuiss/ui@latest add tip
$ pnpm dlx @esuiss/ui@latest add tip --platform swift
NextTop BannerPreviousSwipe Actions

On this page

  1. 01Installation
    • CLI
    • Manual
  2. 02Usage
  3. 03API Reference
    • TipGroup
    • Tip
    • TipCard
    • TipAnchor
    • TipPopover
    • TipIcon
    • TipContent
    • TipTitle
    • TipDescription
    • TipClose
  4. 04Accessibility
  5. 05Notes
  6. 06Installation
    • Manual
  7. 07Usage
  8. 08Examples
    • Card
  9. 09API Reference
    • TipGroup
    • Tip
    • TipCard
    • TipAnchor
    • TipPopover
    • TipIcon
    • TipContent
    • TipTitle
    • TipDescription
    • TipClose
  10. 10Accessibility
  11. 11Notes
from
"@/components/interactions/tip"
/>
$ pnpm dlx @esuiss/ui@latest add tip --platform kotlin

The CLI also adds what it builds on: Icon, Text. On React Native it also adds the animated view components/ui/animated-view.tsx.

Manual

  1. Install the dependencies:
npx expo install cn phosphor-react-native react-native-reanimated react-native-screens react-native-svg react-native-worklets
  1. Add the pieces it builds on: Icon, 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.

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

uniwind/components/ui/animated-view.tsx
import { Pressable } from "react-native"
import Animated from "react-native-reanimated"

const AnimatedPressable = Animated.createAnimatedComponent(Pressable)

type AnimatedViewProps =
  | (React.ComponentProps<typeof Animated.View> &
      React.RefAttributes<React.ComponentRef<typeof Animated.View>> & {
        as?: "View"
      })
  | (React.ComponentProps<typeof AnimatedPressable> &
      React.RefAttributes<React.ComponentRef<typeof AnimatedPressable>> & {
        as: "Pressable"
      })

function AnimatedView(props: AnimatedViewProps) {
  1. Copy the source into components/interactions/tip.tsx:
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,
  1. Update the import paths to match your project.
  1. Install the dependencies:
npx expo install cn phosphor-react-native react-native-reanimated react-native-screens react-native-svg react-native-worklets
  1. Add the pieces it builds on: Icon, 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.

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

  1. Copy the source into components/interactions/tip.tsx:
  1. Update the import paths to match your project.
  1. Install the dependencies:
npx expo install phosphor-react-native react-native-reanimated react-native-screens react-native-svg react-native-worklets
  1. Add the pieces it builds on: Icon, 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.

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

  1. Copy the source into components/interactions/tip.tsx:
  1. Update the import paths to match your project.
  1. Target iOS 17 or later.

  2. Add the Swift package to your app:

Package.swift
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
  1. Copy the theme files every view uses:
swift/Theme/Theme.swift
import SwiftUI
import UIKit

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

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

public enum EsuissColor {
  public static let background = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x000000))
  public static let foreground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let card = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x1D1D1F))
  public static let cardForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let popover = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x1D1D1F))
  public static let popoverForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let primary = Color.adaptive(light: .init(0x0071E3), dark: .init(0x2997FF))
  public static let primaryForeground = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0xFFFFFF))
  public static let secondary = Color.adaptive(light: .init(0x000000, 0.04), dark: .init(0xFFFFFF, 0.1))
  public static let secondaryForeground = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let muted = Color.adaptive(light: .init(0xF5F5F7), dark: .init(0x161617))
  public static let mutedForeground = Color.adaptive(light: .init(0x6E6E73), dark: .init(0xA1A1A6))
  public static let accent = Color.adaptive(light: .init(0x000000, 0.06), dark: .init(0xFFFFFF, 0.1))
  1. Copy the source and every file it uses:
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? {
swift/Components/TextVariant.swift
import SwiftUI

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

  var style: TextStyle {
    switch self {
    case .h1: .xl3
    case .h2: .xl2
    case .h3: .xl
    case .h4, .lead, .large: .lg
    case .code, .small, .muted: .sm
    default: .base
    }
  }
  1. Add the files to your app target.
  1. Set minSdk to 24 or higher and add the dependencies:
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")
  1. Copy the theme files every view uses:
kotlin/theme/Theme.kt
package com.esuiss.ui.theme

import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.shadow.Shadow as DropShadow
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
kotlin/theme/Tokens.kt
package com.esuiss.ui.theme

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

@Immutable
data class Colors(
    val background: Color,
    val foreground: Color,
    val card: Color,
    val cardForeground: Color,
    val popover: Color,
    val popoverForeground: Color,
    val primary: Color,
    val primaryForeground: Color,
  1. Copy the source and every file it uses:
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
kotlin/components/Button.kt
package com.esuiss.ui.components

import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
kotlin/components/ButtonGroup.kt
package com.esuiss.ui.components

import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
kotlin/components/Content.kt
package com.esuiss.ui.components

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

val LocalContentStyle = compositionLocalOf { TextStyle.Default }

val LocalContentColor = compositionLocalOf { Color.Unspecified }

val LocalIconSize = compositionLocalOf { 20.dp }
kotlin/components/FormSupport.kt
package com.esuiss.ui.components

import android.provider.Settings
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
kotlin/components/Icon.kt
package com.esuiss.ui.components

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

@Composable
fun Icon(
    imageVector: ImageVector,
kotlin/components/Overlay.kt
package com.esuiss.ui.components

import android.os.Build
import android.view.Window
import android.view.WindowManager
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.AnimatedVisibilityScope
import androidx.compose.animation.EnterExitState
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideIn
  1. Update the com.esuiss.ui package names to match your project.

Usage

import { TipGroup, Tip, TipCard, TipAnchor, TipPopover, TipIcon, TipContent, TipTitle, TipDescription, TipClose } from "@/components/interactions/tip"
<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.

iOS
Tip (card) on iOS in light modeTip (card) on iOS in dark mode
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 />
  )
}
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
    }
  }
}
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

01

TipGroup

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

PropTypeDefaultDescription
storageTipStoragememoryStorageAdapter with getItem and setItem for remembering dismissals. Defaults to in-memory storage.
childrenReact.ReactNode-
02

Tip

One hint, identified by id.

PropTypeDefaultDescription
idstringRequiredStable key used to remember the dismissal.
delaynumber0Milliseconds to wait before showing. Defaults to 0.
onDismiss() => void-Called when the tip is dismissed.
childrenReact.ReactNode-
03

TipCard

Renders the tip inline as a card.

Also accepts the props of ViewProps.

04

TipAnchor

The element the popover points at.

Also accepts the props of ViewProps.

05

TipPopover

Renders the tip as a popover next to the anchor.

PropTypeDefaultDescription
sideSide"bottom"Side of the anchor to show on. Defaults to bottom.
alignAlign"center"Alignment against the anchor. Defaults to center.
sideOffsetnumber10Gap from the anchor. Defaults to 10.
portalHoststring-Name of a portal host to render into.

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

06

TipIcon

A decorative icon slot.

Also accepts the props of ViewProps.

07

TipContent

Holds the title and description.

Also accepts the props of ViewProps.

08

TipTitle

The tip's heading.

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

09

TipDescription

The tip's supporting text.

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

10

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.
iOS
Tip on iOS in light modeTip on iOS in dark mode
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 />
  )
}
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
    }
  }
}
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,
        )
    }
}
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>
}
nativewind/components/ui/animated-view.tsx
import { Pressable } from "react-native"
import Animated from "react-native-reanimated"

const AnimatedPressable = Animated.createAnimatedComponent(Pressable)

type AnimatedViewProps =
  | (React.ComponentProps<typeof Animated.View> &
      React.RefAttributes<React.ComponentRef<typeof Animated.View>> & {
        as?: "View"
      })
  | (React.ComponentProps<typeof AnimatedPressable> &
      React.RefAttributes<React.ComponentRef<typeof AnimatedPressable>> & {
        as: "Pressable"
      })

function AnimatedView(props: AnimatedViewProps) {
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,
stylesheet/components/ui/animated-view.tsx
import { Pressable } from "react-native"
import Animated from "react-native-reanimated"

const AnimatedPressable = Animated.createAnimatedComponent(Pressable)

type AnimatedViewProps =
  | (React.ComponentProps<typeof Animated.View> &
      React.RefAttributes<React.ComponentRef<typeof Animated.View>> & {
        as?: "View"
      })
  | (React.ComponentProps<typeof AnimatedPressable> &
      React.RefAttributes<React.ComponentRef<typeof AnimatedPressable>> & {
        as: "Pressable"
      })

function AnimatedView(props: AnimatedViewProps) {
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,