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

Top Banner

Short banners that slide in from the top of the screen, queue one at a time and can be swiped up to dismiss.

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

iOS
Top Banner on iOS in light modeTop Banner on iOS in dark mode
import { WifiSlashIcon } from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import { TopBannerProvider, useTopBanner } from "@/components/interactions/top-banner"
import { Button } from "@/components/ui/button"
import { Text } from "@/components/ui/text"

function Triggers() {
  const { show, dismiss } = useTopBanner()
  const [log, setLog] = React.useState("")
  return (
    <View className="w-full gap-3">
      <Button
        variant="outline"
        onPress={() =>
          show({
            title: "Message sent",
            description: "Alex will see it the next time they open the app.",
          })
        }
      >
        <Text>Show banner</Text>
      </Button>
      <Button
        variant="outline"
        onPress={() =>
          show({
            icon: WifiSlashIcon,
            title: "You are offline",
            description: "Changes will sync when you reconnect.",
            action: { label: "Retry", onPress: () => setLog("Retry pressed") },
          })
        }
      >
        <Text>Show with icon and action</Text>
      </Button>
      <Button
        variant="outline"
        onPress={() => {
          show({ title: "First of three" })
          show({ title: "Second of three" })
          show({ title: "Third of three" })
        }}
      >
        <Text>Queue three</Text>
      </Button>
      <Button variant="ghost" onPress={() => dismiss()}>
        <Text>Dismiss current</Text>
      </Button>
      <Text role="status" className="text-sm text-muted-foreground">
        {log}
      </Text>
    </View>
  )
}

export function TopBannerExample() {
  return (
    <TopBannerProvider>
      <Triggers />
    </TopBannerProvider>
  )
}
import EsuissUI
import PhosphorSwift
import SwiftUI

struct TopBannerExample: View {
  var body: some View {
    TopBannerStage(timeout: .seconds(4))
  }
}

struct TopBannerStage: View {
  var timeout: Duration = .seconds(600)
  var onMount: (@MainActor (TopBannerManager) -> Void)?

  @State private var manager = TopBannerManager()
  @State private var shown = false

  init(timeout: Duration = .seconds(600), onMount: (@MainActor (TopBannerManager) -> Void)? = nil) {
    self.timeout = timeout
    self.onMount = onMount
  }

  var body: some View {
    TopBannerTriggers(manager: manager)
      .frame(maxHeight: .infinity, alignment: .top)
      .containerRelativeFrame(.vertical) { height, _ in height - 32 }
      .topBanner(manager, timeout: timeout)
      .onAppear {
        guard !shown else { return }
        shown = true
        manager.timeout = timeout
        onMount?(manager)
      }
  }
}

struct TopBannerTriggers: View {
  let manager: TopBannerManager

  @State private var log = ""

  var body: some View {
    VStack(spacing: 12) {
      Button {
        manager.show("Message sent", description: "Alex will see it the next time they open the app.")
      } label: {
        Text("Show banner").frame(maxWidth: .infinity)
      }
      .buttonStyle(.variant(.outline))
      Button {
        manager.show(
          "You are offline",
          description: "Changes will sync when you reconnect.",
          icon: Ph.wifiSlash.regular,
          action: TopBannerAction("Retry") { log = "Retry pressed" }
        )
      } label: {
        Text("Show with icon and action").frame(maxWidth: .infinity)
      }
      .buttonStyle(.variant(.outline))
      Button {
        manager.show("First of three")
        manager.show("Second of three")
        manager.show("Third of three")
      } label: {
        Text("Queue three").frame(maxWidth: .infinity)
      }
      .buttonStyle(.variant(.outline))
      Button {
        manager.dismiss()
      } label: {
        Text("Dismiss current").frame(maxWidth: .infinity)
      }
      .buttonStyle(.variant(.ghost))
      Text(verbatim: log)
        .textStyle(.sm)
        .foregroundStyle(EsuissColor.mutedForeground)
        .frame(maxWidth: .infinity, alignment: .leading)
        .accessibilityAddTraits(.updatesFrequently)
    }
  }
}
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.WifiSlash
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Text
import com.esuiss.ui.interactions.TopBannerAction
import com.esuiss.ui.interactions.TopBannerProvider
import com.esuiss.ui.interactions.TopBannerState
import com.esuiss.ui.interactions.rememberTopBannerState
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

@Composable
fun TopBannerExample() {
    TopBannerDemo(timeout = 4000L)
}

@Composable
fun TopBannerDemo(timeout: Long = 600000L, onMount: (TopBannerState.() -> Unit)? = null) {
    val state = rememberTopBannerState(defaultTimeoutMillis = timeout)
    var shown by rememberSaveable { mutableStateOf(false) }
    LaunchedEffect(Unit) {
        if (shown || onMount == null) return@LaunchedEffect
        shown = true
        state.onMount()
    }
    TopBannerProvider(state) {
        TopBannerTriggers(state)
    }
}

@Composable
fun TopBannerTriggers(state: TopBannerState) {
    var log by rememberSaveable { mutableStateOf("") }
    val fill = Modifier.fillMaxWidth()
    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
        Button(
            "Show banner",
            onClick = { state.show("Message sent", description = "Alex will see it the next time they open the app.") },
            modifier = fill,
            variant = ButtonVariant.Outline,
        )
        Button(
            "Show with icon and action",
            onClick = {
                state.show(
                    "You are offline",
                    description = "Changes will sync when you reconnect.",
                    icon = PhosphorIcons.Regular.WifiSlash,
                    action = TopBannerAction("Retry") { log = "Retry pressed" },
                )
            },
            modifier = fill,
            variant = ButtonVariant.Outline,
        )
        Button(
            "Queue three",
            onClick = {
                state.show("First of three")
                state.show("Second of three")
                state.show("Third of three")
            },
            modifier = fill,
            variant = ButtonVariant.Outline,
        )
        Button("Dismiss current", onClick = { state.dismiss() }, modifier = fill, variant = ButtonVariant.Ghost)
        Text(
            log,
            modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
            style = Typography.sm.copy(color = EsuissTheme.colors.mutedForeground),
        )
    }
}

TopBannerProvider shows banners requested with show from useTopBanner. Each banner slides down below the top safe area with a title, an optional description, icon and action button, and hides after 4 seconds by default.

Banners queue and appear one at a time, with a short pause between them. Swiping a banner up past a small distance, or flicking it up, dismisses it; dragging pauses the timer. Use it for brief confirmations and status updates, such as a copied link or a restored connection.

Installation

$ pnpm dlx shadcn@latest add @esuiss-rn/top-banner

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

$ pnpm dlx @esuiss/ui@latest add top-banner
$ pnpm dlx @esuiss/ui@latest add top-banner --platform swift
$ pnpm dlx @esuiss/ui@latest add top-banner --platform kotlin

The CLI also adds what it builds on: Button, 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-gesture-handler react-native-reanimated react-native-safe-area-context react-native-svg react-native-worklets
  1. Add the pieces it builds on: Button, Icon, Text, and the primitives components/primitives/portal.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/top-banner.tsx:
uniwind/components/interactions/top-banner.tsx
import { cn } from "cn"
import type { Icon as PhosphorIcon } from "phosphor-react-native"
import * as React from "react"
import { AccessibilityInfo, useWindowDimensions, View } from "react-native"
import { Gesture, GestureDetector } from "react-native-gesture-handler"
import {
  Easing,
  Keyframe,
  ReduceMotion,
  runOnJS,
  useAnimatedStyle,
  useSharedValue,
  withSpring,
  withTiming,
} from "react-native-reanimated"
import { useSafeAreaInsets } from "react-native-safe-area-context"
  1. Update the import paths to match your project.
  1. Install the dependencies:
npx expo install cn phosphor-react-native react-native-gesture-handler react-native-reanimated react-native-safe-area-context react-native-svg react-native-worklets
  1. Add the pieces it builds on: Button, Icon, Text, and the primitives components/primitives/portal.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:

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) {
  1. Copy the source into components/interactions/top-banner.tsx:
nativewind/components/interactions/top-banner.tsx
import { cn } from "cn"
import type { Icon as PhosphorIcon } from "phosphor-react-native"
import * as React from "react"
import { AccessibilityInfo, useWindowDimensions, View } from "react-native"
import { Gesture, GestureDetector } from "react-native-gesture-handler"
import {
  Easing,
  Keyframe,
  ReduceMotion,
  runOnJS,
  useAnimatedStyle,
  useSharedValue,
  withSpring,
  withTiming,
} from "react-native-reanimated"
import { useSafeAreaInsets } from "react-native-safe-area-context"
  1. Update the import paths to match your project.
  1. Install the dependencies:
npx expo install phosphor-react-native react-native-gesture-handler react-native-reanimated react-native-safe-area-context react-native-svg react-native-worklets
  1. Add the pieces it builds on: Button, Icon, Text, and the primitives components/primitives/portal.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:

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) {
  1. Copy the source into components/interactions/top-banner.tsx:
stylesheet/components/interactions/top-banner.tsx
import type { Icon as PhosphorIcon } from "phosphor-react-native"
import * as React from "react"
import {
  AccessibilityInfo,
  type StyleProp,
  StyleSheet,
  type TextStyle,
  useWindowDimensions,
  View,
  type ViewStyle,
} from "react-native"
import { Gesture, GestureDetector } from "react-native-gesture-handler"
import {
  Easing,
  Keyframe,
  ReduceMotion,
  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/TopBanner.swift
import Observation
import PhosphorSwift
import SwiftUI

public struct TopBannerAction: Sendable {
  public let title: LocalizedStringResource
  public let action: @MainActor @Sendable () -> Void

  public init(_ title: LocalizedStringResource, action: @escaping @MainActor @Sendable () -> Void) {
    self.title = title
    self.action = action
  }
}

public struct TopBannerItem: Identifiable, @unchecked Sendable {
  public let id: UUID
swift/Components/Button.swift
import SwiftUI

public struct VariantButtonStyle: ButtonStyle {
  public enum Variant: CaseIterable, Sendable {
    case `default`, outline, secondary, tinted, ghost, plain, destructive, link
  }

  public enum Size: CaseIterable, Sendable {
    case `default`, xs, sm, lg, xl, icon, iconXs, iconSm, iconLg, iconXl

    var height: CGFloat {
      switch self {
      case .xs, .iconXs: 24
      case .sm, .iconSm: 32
      case .default, .icon: 36
      case .lg, .iconLg: 44
swift/Components/ButtonGroup.swift
import SwiftUI

extension EnvironmentValues {
  @Entry var buttonGroup: Axis? = nil
  @Entry var buttonGroupSize: CGSize = .zero
}

enum ButtonGroupSpace: Hashable {
  case group
}

struct ButtonGroupFrame {
  let bounds: Anchor<CGRect>
  let outlined: Bool
}
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")
  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/TopBanner.kt
package com.esuiss.ui.interactions

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
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.WindowInsets
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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,
  1. Update the com.esuiss.ui package names to match your project.

Usage

import { TopBannerProvider } from "@/components/interactions/top-banner"
<TopBannerProvider>
  <Triggers />
</TopBannerProvider>

Wrap the app in TopBannerProvider, then call show({ title, description?, icon?, action?, timeout? }) from useTopBanner. show returns an id that you can pass to dismiss; calling dismiss() with no id dismisses the current banner.

icon is a Phosphor icon component. action is { label, onPress }; pressing it runs onPress and dismisses the banner. Pass a timeout of 0 or Infinity to keep a banner until dismissed.

Examples

Showing

A banner with a title and description, shown on mount.

iOS
Top Banner (showing) on iOS in light modeTop Banner (showing) on iOS in dark mode
import { WifiSlashIcon } from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import { TopBannerProvider, useTopBanner } from "@/components/interactions/top-banner"
import { Button } from "@/components/ui/button"
import { Text } from "@/components/ui/text"

function Triggers() {
  const { show, dismiss } = useTopBanner()
  const [log, setLog] = React.useState("")
  return (
    <View className="w-full gap-3">
      <Button
        variant="outline"
        onPress={() =>
          show({
            title: "Message sent",
            description: "Alex will see it the next time they open the app.",
          })
        }
      >
        <Text>Show banner</Text>
      </Button>
      <Button
        variant="outline"
        onPress={() =>
          show({
            icon: WifiSlashIcon,
            title: "You are offline",
            description: "Changes will sync when you reconnect.",
            action: { label: "Retry", onPress: () => setLog("Retry pressed") },
          })
        }
      >
        <Text>Show with icon and action</Text>
      </Button>
      <Button
        variant="outline"
        onPress={() => {
          show({ title: "First of three" })
          show({ title: "Second of three" })
          show({ title: "Third of three" })
        }}
      >
        <Text>Queue three</Text>
      </Button>
      <Button variant="ghost" onPress={() => dismiss()}>
        <Text>Dismiss current</Text>
      </Button>
      <Text role="status" className="text-sm text-muted-foreground">
        {log}
      </Text>
    </View>
  )
}

function ShowOnMount({
  options,
}: {
  options: Parameters<ReturnType<typeof useTopBanner>["show"]>[0]
}) {
  const { show } = useTopBanner()
  const shownRef = React.useRef(false)
  const optionsRef = React.useRef(options)

  React.useEffect(() => {
    if (shownRef.current) return
    shownRef.current = true
    show(optionsRef.current)
  }, [show])

  return <Triggers />
}

export function TopBannerShowingExample() {
  return (
    <TopBannerProvider timeout={600000}>
      <ShowOnMount
        options={{
          title: "Message sent",
          description: "Alex will see it the next time they open the app.",
        }}
      />
    </TopBannerProvider>
  )
}
import EsuissUI
import PhosphorSwift
import SwiftUI

struct TopBannerShowingExample: View {
  var body: some View {
    TopBannerStage { manager in
      manager.show("Message sent", description: "Alex will see it the next time they open the app.")
    }
  }
}

struct TopBannerStage: View {
  var timeout: Duration = .seconds(600)
  var onMount: (@MainActor (TopBannerManager) -> Void)?

  @State private var manager = TopBannerManager()
  @State private var shown = false

  init(timeout: Duration = .seconds(600), onMount: (@MainActor (TopBannerManager) -> Void)? = nil) {
    self.timeout = timeout
    self.onMount = onMount
  }

  var body: some View {
    TopBannerTriggers(manager: manager)
      .frame(maxHeight: .infinity, alignment: .top)
      .containerRelativeFrame(.vertical) { height, _ in height - 32 }
      .topBanner(manager, timeout: timeout)
      .onAppear {
        guard !shown else { return }
        shown = true
        manager.timeout = timeout
        onMount?(manager)
      }
  }
}

struct TopBannerTriggers: View {
  let manager: TopBannerManager

  @State private var log = ""

  var body: some View {
    VStack(spacing: 12) {
      Button {
        manager.show("Message sent", description: "Alex will see it the next time they open the app.")
      } label: {
        Text("Show banner").frame(maxWidth: .infinity)
      }
      .buttonStyle(.variant(.outline))
      Button {
        manager.show(
          "You are offline",
          description: "Changes will sync when you reconnect.",
          icon: Ph.wifiSlash.regular,
          action: TopBannerAction("Retry") { log = "Retry pressed" }
        )
      } label: {
        Text("Show with icon and action").frame(maxWidth: .infinity)
      }
      .buttonStyle(.variant(.outline))
      Button {
        manager.show("First of three")
        manager.show("Second of three")
        manager.show("Third of three")
      } label: {
        Text("Queue three").frame(maxWidth: .infinity)
      }
      .buttonStyle(.variant(.outline))
      Button {
        manager.dismiss()
      } label: {
        Text("Dismiss current").frame(maxWidth: .infinity)
      }
      .buttonStyle(.variant(.ghost))
      Text(verbatim: log)
        .textStyle(.sm)
        .foregroundStyle(EsuissColor.mutedForeground)
        .frame(maxWidth: .infinity, alignment: .leading)
        .accessibilityAddTraits(.updatesFrequently)
    }
  }
}
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.WifiSlash
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Text
import com.esuiss.ui.interactions.TopBannerAction
import com.esuiss.ui.interactions.TopBannerProvider
import com.esuiss.ui.interactions.TopBannerState
import com.esuiss.ui.interactions.rememberTopBannerState
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

@Composable
fun TopBannerShowingExample() {
    TopBannerDemo { show("Message sent", description = "Alex will see it the next time they open the app.") }
}

@Composable
fun TopBannerDemo(timeout: Long = 600000L, onMount: (TopBannerState.() -> Unit)? = null) {
    val state = rememberTopBannerState(defaultTimeoutMillis = timeout)
    var shown by rememberSaveable { mutableStateOf(false) }
    LaunchedEffect(Unit) {
        if (shown || onMount == null) return@LaunchedEffect
        shown = true
        state.onMount()
    }
    TopBannerProvider(state) {
        TopBannerTriggers(state)
    }
}

@Composable
fun TopBannerTriggers(state: TopBannerState) {
    var log by rememberSaveable { mutableStateOf("") }
    val fill = Modifier.fillMaxWidth()
    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
        Button(
            "Show banner",
            onClick = { state.show("Message sent", description = "Alex will see it the next time they open the app.") },
            modifier = fill,
            variant = ButtonVariant.Outline,
        )
        Button(
            "Show with icon and action",
            onClick = {
                state.show(
                    "You are offline",
                    description = "Changes will sync when you reconnect.",
                    icon = PhosphorIcons.Regular.WifiSlash,
                    action = TopBannerAction("Retry") { log = "Retry pressed" },
                )
            },
            modifier = fill,
            variant = ButtonVariant.Outline,
        )
        Button(
            "Queue three",
            onClick = {
                state.show("First of three")
                state.show("Second of three")
                state.show("Third of three")
            },
            modifier = fill,
            variant = ButtonVariant.Outline,
        )
        Button("Dismiss current", onClick = { state.dismiss() }, modifier = fill, variant = ButtonVariant.Ghost)
        Text(
            log,
            modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
            style = Typography.sm.copy(color = EsuissTheme.colors.mutedForeground),
        )
    }
}

With icon

A banner with a leading icon.

iOS
Top Banner (with icon) on iOS in light modeTop Banner (with icon) on iOS in dark mode
import { BellIcon, WifiSlashIcon } from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import { TopBannerProvider, useTopBanner } from "@/components/interactions/top-banner"
import { Button } from "@/components/ui/button"
import { Text } from "@/components/ui/text"

function Triggers() {
  const { show, dismiss } = useTopBanner()
  const [log, setLog] = React.useState("")
  return (
    <View className="w-full gap-3">
      <Button
        variant="outline"
        onPress={() =>
          show({
            title: "Message sent",
            description: "Alex will see it the next time they open the app.",
          })
        }
      >
        <Text>Show banner</Text>
      </Button>
      <Button
        variant="outline"
        onPress={() =>
          show({
            icon: WifiSlashIcon,
            title: "You are offline",
            description: "Changes will sync when you reconnect.",
            action: { label: "Retry", onPress: () => setLog("Retry pressed") },
          })
        }
      >
        <Text>Show with icon and action</Text>
      </Button>
      <Button
        variant="outline"
        onPress={() => {
          show({ title: "First of three" })
          show({ title: "Second of three" })
          show({ title: "Third of three" })
        }}
      >
        <Text>Queue three</Text>
      </Button>
      <Button variant="ghost" onPress={() => dismiss()}>
        <Text>Dismiss current</Text>
      </Button>
      <Text role="status" className="text-sm text-muted-foreground">
        {log}
      </Text>
    </View>
  )
}

function ShowOnMount({
  options,
}: {
  options: Parameters<ReturnType<typeof useTopBanner>["show"]>[0]
}) {
  const { show } = useTopBanner()
  const shownRef = React.useRef(false)
  const optionsRef = React.useRef(options)

  React.useEffect(() => {
    if (shownRef.current) return
    shownRef.current = true
    show(optionsRef.current)
  }, [show])

  return <Triggers />
}

export function TopBannerWithIconExample() {
  return (
    <TopBannerProvider timeout={600000}>
      <ShowOnMount
        options={{
          icon: BellIcon,
          title: "Reminders are on",
          description: "We will remind you 10 minutes before each event.",
        }}
      />
    </TopBannerProvider>
  )
}
import EsuissUI
import PhosphorSwift
import SwiftUI

struct TopBannerWithIconExample: View {
  var body: some View {
    TopBannerStage { manager in
      manager.show(
        "Reminders are on",
        description: "We will remind you 10 minutes before each event.",
        icon: Ph.bell.regular
      )
    }
  }
}

struct TopBannerStage: View {
  var timeout: Duration = .seconds(600)
  var onMount: (@MainActor (TopBannerManager) -> Void)?

  @State private var manager = TopBannerManager()
  @State private var shown = false

  init(timeout: Duration = .seconds(600), onMount: (@MainActor (TopBannerManager) -> Void)? = nil) {
    self.timeout = timeout
    self.onMount = onMount
  }

  var body: some View {
    TopBannerTriggers(manager: manager)
      .frame(maxHeight: .infinity, alignment: .top)
      .containerRelativeFrame(.vertical) { height, _ in height - 32 }
      .topBanner(manager, timeout: timeout)
      .onAppear {
        guard !shown else { return }
        shown = true
        manager.timeout = timeout
        onMount?(manager)
      }
  }
}

struct TopBannerTriggers: View {
  let manager: TopBannerManager

  @State private var log = ""

  var body: some View {
    VStack(spacing: 12) {
      Button {
        manager.show("Message sent", description: "Alex will see it the next time they open the app.")
      } label: {
        Text("Show banner").frame(maxWidth: .infinity)
      }
      .buttonStyle(.variant(.outline))
      Button {
        manager.show(
          "You are offline",
          description: "Changes will sync when you reconnect.",
          icon: Ph.wifiSlash.regular,
          action: TopBannerAction("Retry") { log = "Retry pressed" }
        )
      } label: {
        Text("Show with icon and action").frame(maxWidth: .infinity)
      }
      .buttonStyle(.variant(.outline))
      Button {
        manager.show("First of three")
        manager.show("Second of three")
        manager.show("Third of three")
      } label: {
        Text("Queue three").frame(maxWidth: .infinity)
      }
      .buttonStyle(.variant(.outline))
      Button {
        manager.dismiss()
      } label: {
        Text("Dismiss current").frame(maxWidth: .infinity)
      }
      .buttonStyle(.variant(.ghost))
      Text(verbatim: log)
        .textStyle(.sm)
        .foregroundStyle(EsuissColor.mutedForeground)
        .frame(maxWidth: .infinity, alignment: .leading)
        .accessibilityAddTraits(.updatesFrequently)
    }
  }
}
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.Bell
import com.adamglin.phosphoricons.regular.WifiSlash
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Text
import com.esuiss.ui.interactions.TopBannerAction
import com.esuiss.ui.interactions.TopBannerProvider
import com.esuiss.ui.interactions.TopBannerState
import com.esuiss.ui.interactions.rememberTopBannerState
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

@Composable
fun TopBannerWithIconExample() {
    TopBannerDemo {
        show(
            "Reminders are on",
            description = "We will remind you 10 minutes before each event.",
            icon = PhosphorIcons.Regular.Bell,
        )
    }
}

@Composable
fun TopBannerDemo(timeout: Long = 600000L, onMount: (TopBannerState.() -> Unit)? = null) {
    val state = rememberTopBannerState(defaultTimeoutMillis = timeout)
    var shown by rememberSaveable { mutableStateOf(false) }
    LaunchedEffect(Unit) {
        if (shown || onMount == null) return@LaunchedEffect
        shown = true
        state.onMount()
    }
    TopBannerProvider(state) {
        TopBannerTriggers(state)
    }
}

@Composable
fun TopBannerTriggers(state: TopBannerState) {
    var log by rememberSaveable { mutableStateOf("") }
    val fill = Modifier.fillMaxWidth()
    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
        Button(
            "Show banner",
            onClick = { state.show("Message sent", description = "Alex will see it the next time they open the app.") },
            modifier = fill,
            variant = ButtonVariant.Outline,
        )
        Button(
            "Show with icon and action",
            onClick = {
                state.show(
                    "You are offline",
                    description = "Changes will sync when you reconnect.",
                    icon = PhosphorIcons.Regular.WifiSlash,
                    action = TopBannerAction("Retry") { log = "Retry pressed" },
                )
            },
            modifier = fill,
            variant = ButtonVariant.Outline,
        )
        Button(
            "Queue three",
            onClick = {
                state.show("First of three")
                state.show("Second of three")
                state.show("Third of three")
            },
            modifier = fill,
            variant = ButtonVariant.Outline,
        )
        Button("Dismiss current", onClick = { state.dismiss() }, modifier = fill, variant = ButtonVariant.Ghost)
        Text(
            log,
            modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
            style = Typography.sm.copy(color = EsuissTheme.colors.mutedForeground),
        )
    }
}

With action

A banner with an action button.

iOS
Top Banner (with action) on iOS in light modeTop Banner (with action) on iOS in dark mode
import { CloudArrowUpIcon, WifiSlashIcon } from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import { TopBannerProvider, useTopBanner } from "@/components/interactions/top-banner"
import { Button } from "@/components/ui/button"
import { Text } from "@/components/ui/text"

function Triggers() {
  const { show, dismiss } = useTopBanner()
  const [log, setLog] = React.useState("")
  return (
    <View className="w-full gap-3">
      <Button
        variant="outline"
        onPress={() =>
          show({
            title: "Message sent",
            description: "Alex will see it the next time they open the app.",
          })
        }
      >
        <Text>Show banner</Text>
      </Button>
      <Button
        variant="outline"
        onPress={() =>
          show({
            icon: WifiSlashIcon,
            title: "You are offline",
            description: "Changes will sync when you reconnect.",
            action: { label: "Retry", onPress: () => setLog("Retry pressed") },
          })
        }
      >
        <Text>Show with icon and action</Text>
      </Button>
      <Button
        variant="outline"
        onPress={() => {
          show({ title: "First of three" })
          show({ title: "Second of three" })
          show({ title: "Third of three" })
        }}
      >
        <Text>Queue three</Text>
      </Button>
      <Button variant="ghost" onPress={() => dismiss()}>
        <Text>Dismiss current</Text>
      </Button>
      <Text role="status" className="text-sm text-muted-foreground">
        {log}
      </Text>
    </View>
  )
}

function ShowOnMount({
  options,
}: {
  options: Parameters<ReturnType<typeof useTopBanner>["show"]>[0]
}) {
  const { show } = useTopBanner()
  const shownRef = React.useRef(false)
  const optionsRef = React.useRef(options)

  React.useEffect(() => {
    if (shownRef.current) return
    shownRef.current = true
    show(optionsRef.current)
  }, [show])

  return <Triggers />
}

export function TopBannerWithActionExample() {
  return (
    <TopBannerProvider timeout={600000}>
      <ShowOnMount
        options={{
          icon: CloudArrowUpIcon,
          title: "Upload paused",
          description: "3 photos are waiting for Wi-Fi.",
          action: { label: "Resume", onPress: () => undefined },
        }}
      />
    </TopBannerProvider>
  )
}
import EsuissUI
import PhosphorSwift
import SwiftUI

struct TopBannerWithActionExample: View {
  var body: some View {
    TopBannerStage { manager in
      manager.show(
        "Upload paused",
        description: "3 photos are waiting for Wi-Fi.",
        icon: Ph.cloudArrowUp.regular,
        action: TopBannerAction("Resume") {}
      )
    }
  }
}

struct TopBannerStage: View {
  var timeout: Duration = .seconds(600)
  var onMount: (@MainActor (TopBannerManager) -> Void)?

  @State private var manager = TopBannerManager()
  @State private var shown = false

  init(timeout: Duration = .seconds(600), onMount: (@MainActor (TopBannerManager) -> Void)? = nil) {
    self.timeout = timeout
    self.onMount = onMount
  }

  var body: some View {
    TopBannerTriggers(manager: manager)
      .frame(maxHeight: .infinity, alignment: .top)
      .containerRelativeFrame(.vertical) { height, _ in height - 32 }
      .topBanner(manager, timeout: timeout)
      .onAppear {
        guard !shown else { return }
        shown = true
        manager.timeout = timeout
        onMount?(manager)
      }
  }
}

struct TopBannerTriggers: View {
  let manager: TopBannerManager

  @State private var log = ""

  var body: some View {
    VStack(spacing: 12) {
      Button {
        manager.show("Message sent", description: "Alex will see it the next time they open the app.")
      } label: {
        Text("Show banner").frame(maxWidth: .infinity)
      }
      .buttonStyle(.variant(.outline))
      Button {
        manager.show(
          "You are offline",
          description: "Changes will sync when you reconnect.",
          icon: Ph.wifiSlash.regular,
          action: TopBannerAction("Retry") { log = "Retry pressed" }
        )
      } label: {
        Text("Show with icon and action").frame(maxWidth: .infinity)
      }
      .buttonStyle(.variant(.outline))
      Button {
        manager.show("First of three")
        manager.show("Second of three")
        manager.show("Third of three")
      } label: {
        Text("Queue three").frame(maxWidth: .infinity)
      }
      .buttonStyle(.variant(.outline))
      Button {
        manager.dismiss()
      } label: {
        Text("Dismiss current").frame(maxWidth: .infinity)
      }
      .buttonStyle(.variant(.ghost))
      Text(verbatim: log)
        .textStyle(.sm)
        .foregroundStyle(EsuissColor.mutedForeground)
        .frame(maxWidth: .infinity, alignment: .leading)
        .accessibilityAddTraits(.updatesFrequently)
    }
  }
}
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.CloudArrowUp
import com.adamglin.phosphoricons.regular.WifiSlash
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Text
import com.esuiss.ui.interactions.TopBannerAction
import com.esuiss.ui.interactions.TopBannerProvider
import com.esuiss.ui.interactions.TopBannerState
import com.esuiss.ui.interactions.rememberTopBannerState
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

@Composable
fun TopBannerWithActionExample() {
    TopBannerDemo {
        show(
            "Upload paused",
            description = "3 photos are waiting for Wi-Fi.",
            icon = PhosphorIcons.Regular.CloudArrowUp,
            action = TopBannerAction("Resume") {},
        )
    }
}

@Composable
fun TopBannerDemo(timeout: Long = 600000L, onMount: (TopBannerState.() -> Unit)? = null) {
    val state = rememberTopBannerState(defaultTimeoutMillis = timeout)
    var shown by rememberSaveable { mutableStateOf(false) }
    LaunchedEffect(Unit) {
        if (shown || onMount == null) return@LaunchedEffect
        shown = true
        state.onMount()
    }
    TopBannerProvider(state) {
        TopBannerTriggers(state)
    }
}

@Composable
fun TopBannerTriggers(state: TopBannerState) {
    var log by rememberSaveable { mutableStateOf("") }
    val fill = Modifier.fillMaxWidth()
    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
        Button(
            "Show banner",
            onClick = { state.show("Message sent", description = "Alex will see it the next time they open the app.") },
            modifier = fill,
            variant = ButtonVariant.Outline,
        )
        Button(
            "Show with icon and action",
            onClick = {
                state.show(
                    "You are offline",
                    description = "Changes will sync when you reconnect.",
                    icon = PhosphorIcons.Regular.WifiSlash,
                    action = TopBannerAction("Retry") { log = "Retry pressed" },
                )
            },
            modifier = fill,
            variant = ButtonVariant.Outline,
        )
        Button(
            "Queue three",
            onClick = {
                state.show("First of three")
                state.show("Second of three")
                state.show("Third of three")
            },
            modifier = fill,
            variant = ButtonVariant.Outline,
        )
        Button("Dismiss current", onClick = { state.dismiss() }, modifier = fill, variant = ButtonVariant.Ghost)
        Text(
            log,
            modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
            style = Typography.sm.copy(color = EsuissTheme.colors.mutedForeground),
        )
    }
}

Title only

A banner with only a title.

iOS
Top Banner (title only) on iOS in light modeTop Banner (title only) on iOS in dark mode
import { WifiSlashIcon } from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import { TopBannerProvider, useTopBanner } from "@/components/interactions/top-banner"
import { Button } from "@/components/ui/button"
import { Text } from "@/components/ui/text"

function Triggers() {
  const { show, dismiss } = useTopBanner()
  const [log, setLog] = React.useState("")
  return (
    <View className="w-full gap-3">
      <Button
        variant="outline"
        onPress={() =>
          show({
            title: "Message sent",
            description: "Alex will see it the next time they open the app.",
          })
        }
      >
        <Text>Show banner</Text>
      </Button>
      <Button
        variant="outline"
        onPress={() =>
          show({
            icon: WifiSlashIcon,
            title: "You are offline",
            description: "Changes will sync when you reconnect.",
            action: { label: "Retry", onPress: () => setLog("Retry pressed") },
          })
        }
      >
        <Text>Show with icon and action</Text>
      </Button>
      <Button
        variant="outline"
        onPress={() => {
          show({ title: "First of three" })
          show({ title: "Second of three" })
          show({ title: "Third of three" })
        }}
      >
        <Text>Queue three</Text>
      </Button>
      <Button variant="ghost" onPress={() => dismiss()}>
        <Text>Dismiss current</Text>
      </Button>
      <Text role="status" className="text-sm text-muted-foreground">
        {log}
      </Text>
    </View>
  )
}

function ShowOnMount({
  options,
}: {
  options: Parameters<ReturnType<typeof useTopBanner>["show"]>[0]
}) {
  const { show } = useTopBanner()
  const shownRef = React.useRef(false)
  const optionsRef = React.useRef(options)

  React.useEffect(() => {
    if (shownRef.current) return
    shownRef.current = true
    show(optionsRef.current)
  }, [show])

  return <Triggers />
}

export function TopBannerTitleOnlyExample() {
  return (
    <TopBannerProvider timeout={600000}>
      <ShowOnMount options={{ title: "Copied to clipboard" }} />
    </TopBannerProvider>
  )
}
import EsuissUI
import PhosphorSwift
import SwiftUI

struct TopBannerTitleOnlyExample: View {
  var body: some View {
    TopBannerStage { manager in
      manager.show("Copied to clipboard")
    }
  }
}

struct TopBannerStage: View {
  var timeout: Duration = .seconds(600)
  var onMount: (@MainActor (TopBannerManager) -> Void)?

  @State private var manager = TopBannerManager()
  @State private var shown = false

  init(timeout: Duration = .seconds(600), onMount: (@MainActor (TopBannerManager) -> Void)? = nil) {
    self.timeout = timeout
    self.onMount = onMount
  }

  var body: some View {
    TopBannerTriggers(manager: manager)
      .frame(maxHeight: .infinity, alignment: .top)
      .containerRelativeFrame(.vertical) { height, _ in height - 32 }
      .topBanner(manager, timeout: timeout)
      .onAppear {
        guard !shown else { return }
        shown = true
        manager.timeout = timeout
        onMount?(manager)
      }
  }
}

struct TopBannerTriggers: View {
  let manager: TopBannerManager

  @State private var log = ""

  var body: some View {
    VStack(spacing: 12) {
      Button {
        manager.show("Message sent", description: "Alex will see it the next time they open the app.")
      } label: {
        Text("Show banner").frame(maxWidth: .infinity)
      }
      .buttonStyle(.variant(.outline))
      Button {
        manager.show(
          "You are offline",
          description: "Changes will sync when you reconnect.",
          icon: Ph.wifiSlash.regular,
          action: TopBannerAction("Retry") { log = "Retry pressed" }
        )
      } label: {
        Text("Show with icon and action").frame(maxWidth: .infinity)
      }
      .buttonStyle(.variant(.outline))
      Button {
        manager.show("First of three")
        manager.show("Second of three")
        manager.show("Third of three")
      } label: {
        Text("Queue three").frame(maxWidth: .infinity)
      }
      .buttonStyle(.variant(.outline))
      Button {
        manager.dismiss()
      } label: {
        Text("Dismiss current").frame(maxWidth: .infinity)
      }
      .buttonStyle(.variant(.ghost))
      Text(verbatim: log)
        .textStyle(.sm)
        .foregroundStyle(EsuissColor.mutedForeground)
        .frame(maxWidth: .infinity, alignment: .leading)
        .accessibilityAddTraits(.updatesFrequently)
    }
  }
}
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.WifiSlash
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Text
import com.esuiss.ui.interactions.TopBannerAction
import com.esuiss.ui.interactions.TopBannerProvider
import com.esuiss.ui.interactions.TopBannerState
import com.esuiss.ui.interactions.rememberTopBannerState
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography

@Composable
fun TopBannerTitleOnlyExample() {
    TopBannerDemo { show("Copied to clipboard") }
}

@Composable
fun TopBannerDemo(timeout: Long = 600000L, onMount: (TopBannerState.() -> Unit)? = null) {
    val state = rememberTopBannerState(defaultTimeoutMillis = timeout)
    var shown by rememberSaveable { mutableStateOf(false) }
    LaunchedEffect(Unit) {
        if (shown || onMount == null) return@LaunchedEffect
        shown = true
        state.onMount()
    }
    TopBannerProvider(state) {
        TopBannerTriggers(state)
    }
}

@Composable
fun TopBannerTriggers(state: TopBannerState) {
    var log by rememberSaveable { mutableStateOf("") }
    val fill = Modifier.fillMaxWidth()
    Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
        Button(
            "Show banner",
            onClick = { state.show("Message sent", description = "Alex will see it the next time they open the app.") },
            modifier = fill,
            variant = ButtonVariant.Outline,
        )
        Button(
            "Show with icon and action",
            onClick = {
                state.show(
                    "You are offline",
                    description = "Changes will sync when you reconnect.",
                    icon = PhosphorIcons.Regular.WifiSlash,
                    action = TopBannerAction("Retry") { log = "Retry pressed" },
                )
            },
            modifier = fill,
            variant = ButtonVariant.Outline,
        )
        Button(
            "Queue three",
            onClick = {
                state.show("First of three")
                state.show("Second of three")
                state.show("Third of three")
            },
            modifier = fill,
            variant = ButtonVariant.Outline,
        )
        Button("Dismiss current", onClick = { state.dismiss() }, modifier = fill, variant = ButtonVariant.Ghost)
        Text(
            log,
            modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
            style = Typography.sm.copy(color = EsuissTheme.colors.mutedForeground),
        )
    }
}

API Reference

01

TopBannerProvider

Holds the banner queue and renders the current banner in a portal.

PropTypeDefaultDescription
timeoutnumber4000Default time in milliseconds before a banner hides. Defaults to 4000.
classNamestring-Classes for the banner card.
portalHoststring-Name of a portal host to render into.
childrenReact.ReactNode-

Accessibility

  • The banner has the alert role and is a polite live region.
  • A string title is announced when the banner appears.
  • The banner has a dismiss accessibility action.

Notes

  • Banners are up to 384 points wide and centered.
  • The card uses bg-card, or bg-muted in dark mode, with a border-border outline; the icon uses text-primary.
  • Slide animations respect the system reduce motion setting.
NextUndo BarPreviousTip

On this page

  1. 01Installation
    • Manual
  2. 02Usage
  3. 03Examples
    • Showing
    • With icon
    • With action
    • Title only
  4. 04API Reference
    • TopBannerProvider
  5. 05Accessibility
  6. 06Notes