suiss/ui
  • Docs
  • Components
  • Patterns
  • Interactions
  • Charts
  • Blocks
GitHubGet started
DocsComponentsPatternsInteractionsChartsBlocks
Components82
  • Overview
  • Accordion
  • Alert
  • Alert Dialog
  • Alert Sheet
  • Amount Input
  • Aspect Ratio
  • Attachment
  • Avatar
  • Badge
  • Bottom Bar
  • Breadcrumb
  • Bubble
  • Button
  • Button Group
  • Calendar
  • Card
  • Carousel
  • Chart
  • Checkbox
  • Chip
  • Collapsible
  • Combobox
  • Command
  • Context Menu
  • Dialog
  • Direction
  • Drawer
  • Dropdown Menu
  • Empty
  • FAB
  • Field
  • Fullscreen Menu
  • Haptics
  • Header
  • Hover Card
  • Icon
  • Input
  • Input Accessory Bar
  • Input Group
  • Input OTP
  • Item
  • Kbd
  • Keyboard Scroll View
  • Keypad
  • Label
  • Marker
  • Menubar
  • Message
  • Message Scroller
  • Native Select
  • Navigation Menu
  • Page Indicator
  • Pagination
  • Pin Pad
  • Popover
  • Progress
  • QR Scanner
  • Questionnaire
  • Radio Group
  • Resizable
  • Screen
  • Scroll Area
  • Select
  • Separator
  • Sheet
  • Sidebar
  • Signature Pad
  • Skeleton
  • Slider
  • Spinner
  • Switch
  • Tab Bar
  • Table
  • Tabs
  • Text
  • Textarea
  • Toast
  • Toggle
  • Toggle Group
  • Tooltip
  • Wheel Picker
  • Widget

suiss/ui · Component

Message Scroller

A scroll container for chat transcripts that opens at the latest message and keeps scroll position stable.

Message Scroller is an open-source component 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.

Message Scroller manages scrolling for conversation views. MessageScrollerProvider creates the scroll controller and holds options such as the initial position and auto-scroll. Inside it, MessageScroller is the root, MessageScrollerViewport is the scrollable region, and MessageScrollerContent lists one MessageScrollerItem per message. MessageScrollerButton sits inside the root and jumps to the start or end.

The scroller opens at the end by default, preserves the reading position when older messages are prepended, and can anchor a newly added message to the top of the viewport. Use it for chat and activity feeds; use Scroll Area for general scrollable content.

Installation

CLI

$ pnpm dlx shadcn@latest add @esuiss/message-scroller

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 message-scroller

Manual

  1. Install the dependencies and add the cn helper:
$ pnpm add @base-ui/react @phosphor-icons/react
  1. Add the pieces it builds on: Button, Scroll Area, use-message-scroller.

  2. Copy the source into components/ui/message-scroller.tsx:

  1. Update the import paths to match your project.

Usage

import { MessageScrollerProvider, MessageScroller, MessageScrollerViewport, MessageScrollerContent, MessageScrollerItem, MessageScrollerButton } 
<div










Place every part inside MessageScrollerProvider; the components and hooks throw outside of it. Provider options:

  • defaultScrollPosition: "end" (default), "start", or "last-anchor" to open at the last item marked with scrollAnchor.
  • autoScroll: when true, follows new content at the bottom until the user scrolls away. Defaults to false.
  • scrollEdgeThreshold, scrollPreviousItemPeek and scrollMargin: pixel values that tune edge detection and anchoring (defaults 8, 64 and 0).

Give each item a stable messageId so it can be targeted programmatically. The exported useMessageScroller hook returns scrollToEnd, scrollToStart and scrollToMessage; useMessageScrollerScrollable reports whether each edge can scroll, and useMessageScrollerVisibility reports the visible message IDs.

Examples

Start at top

A scroller that opens at the first message with defaultScrollPosition="start", with buttons for both directions.

API Reference

Anatomy5 parts
<MessageScrollerProvider>
<MessageScroller>
<MessageScrollerViewport>
<MessageScrollerContent />
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
01

MessageScrollerProvider

Creates the scroll controller and provides it to the parts and hooks inside.

PropTypeDefaultDescription
childrenReact.ReactNode-The scroller parts and any components that use the message scroller hooks.

Also accepts the props of MessageScrollerOptions.

02

MessageScroller

The root container, built on a scroll area.

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

03

MessageScrollerViewport

The focusable scrollable region, rendered with a vertical scrollbar.

PropTypeDefaultDescription
preserveScrollOnPrependbooleantrueKeeps the visible messages in place when items are inserted before the first one, such as when loading history.

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

04

MessageScrollerContent

The live log that holds the message items.

PropTypeDefaultDescription
spacerClassNamestring-Class name for the hidden spacer element the scroller appends after the items.

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

05

MessageScrollerItem

Wraps one message and registers it with the controller.

PropTypeDefaultDescription
messageIdstring-A stable ID used for scrollToMessage, visibility tracking and the data-message-id attribute.
scrollAnchorbooleanfalseMarks the item as an anchor; when it is added, the scroller aligns it to the top of the viewport instead of the bottom.

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

06

MessageScrollerButton

A floating button that appears when the scroller can move toward its direction and jumps there on click.

PropTypeDefaultDescription
behaviorScrollBehavior"smooth"The scroll behavior used when jumping, such as smooth or auto.
directionMessageScrollerScrollDirection"end"Which edge the button scrolls to: end or start. It also sets the button's position and icon rotation.

Also accepts the props of useRender.ComponentProps<"button", MessageScrollerButtonState>, React.ComponentProps<typeof Button>.

Accessibility

  • The viewport has role="region", an aria-label of Messages by default, and tabIndex={0} so it can be scrolled with the keyboard.
  • The content has role="log" with aria-relevant="additions", so assistive technology can announce new messages.
  • MessageScrollerButton includes a visually hidden label, Scroll to end or Scroll to start. While it cannot scroll, the button is inert and removed from the tab order.
  • Wheel, touch and scrolling keys stop auto-follow so the user's reading position is respected.

Notes

  • Styling hooks: data-slot values message-scroller, message-scroller-viewport, message-scroller-content, message-scroller-item and message-scroller-button.
  • The root and viewport receive data-pending-scroll until the initial position is applied; the viewport is invisible during that time. They also get data-scrollable with the scrollable edges and data-autoscrolling while a programmatic scroll is running.
  • The button exposes data-active, data-direction, data-variant and data-size. It defaults to the secondary variant and icon-sm size of Button.
  • Items set data-scroll-anchor and use content-visibility: auto to keep long transcripts fast.

MessageScroller is a View that hosts a scroll controller. MessageScrollerViewport renders your messages in an inverted FlatList, so the newest message sits at the bottom and new messages keep the list anchored there through maintainVisibleContentPosition. You pass data in chronological order; the viewport reverses it internally and maps indexes back for renderItem and keyExtractor.

MessageScrollerButton is a floating icon button that animates in with Reanimated when there is more content in its direction. When the user has scrolled up and new messages arrive, it shows an unread count badge.

Installation

$ pnpm dlx shadcn@latest add @esuiss-rn/message-scroller

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

$ pnpm dlx @esuiss/ui@latest add message-scroller
$ pnpm dlx @esuiss/ui@latest add message-scroller --platform swift
NextNative SelectPreviousMessage

On this page

  1. 01Installation
    • CLI
    • Manual
  2. 02Usage
  3. 03Examples
    • Start at top
  4. 04API Reference
    • MessageScrollerProvider
    • MessageScroller
    • MessageScrollerViewport
    • MessageScrollerContent
    • MessageScrollerItem
    • MessageScrollerButton
  5. 05Accessibility
  6. 06Notes
  7. 07Installation
    • Manual
  8. 08Usage
  9. 09Examples
    • Unread while scrolled up
    • Empty thread
    • Load older
  10. 10API Reference
    • MessageScrollerProvider
    • MessageScroller
    • MessageScrollerViewport
    • MessageScrollerItem
    • MessageScrollerButton
  11. 11Accessibility
  12. 12Notes
from
"@/components/ui/message-scroller"
className
=
"h-96 w-96 overflow-hidden rounded-2xl border"
>
<MessageScrollerProvider>
<MessageScroller>
<MessageScrollerViewport>
<MessageScrollerContent className="gap-3 p-4">
<Messages />
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
</div>
</
MessageScrollerProvider
>
$ pnpm dlx @esuiss/ui@latest add message-scroller --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-reanimated react-native-svg react-native-worklets
  1. Add the pieces it builds on: Button, Icon, Text, and the primitives components/primitives/hooks.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/ui/message-scroller.tsx:
  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-svg react-native-worklets
  1. Add the pieces it builds on: Button, Icon, Text, and the primitives components/primitives/hooks.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/ui/message-scroller.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-svg react-native-worklets
  1. Add the pieces it builds on: Button, Icon, Text, and the primitives components/primitives/hooks.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/ui/message-scroller.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:
  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/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 { MessageScrollerProvider, MessageScroller, MessageScrollerViewport, MessageScrollerItem, MessageScrollerButton } from "@/components/ui/message-scroller"
<Chat initial={thread} />

Place MessageScrollerViewport and MessageScrollerButton inside MessageScroller. Wrap everything in MessageScrollerProvider when components outside the scroller, such as a composer, need to call the scroll hooks.

useMessageScroller returns scrollToEnd, scrollToStart and scrollToMessage(id, { align, animated }). useMessageScrollerScrollable, useMessageScrollerUnreadCount and useMessageScrollerVisibility read whether each edge is scrollable, the unread count, and the visible message ids.

Set onLoadOlder to fetch older history when the user scrolls near the top. Other FlatList props such as ListEmptyComponent pass through.

Examples

Unread while scrolled up

New messages arrive while scrolled up, so the button shows an unread count.

iOS
Message Scroller (unread while scrolled up) on iOS in light modeMessage Scroller (unread while scrolled up) on iOS in dark mode
import { ArrowUpIcon, ChecksIcon, XIcon } from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import { Attachment, AttachmentAction, AttachmentActions, AttachmentContent, AttachmentDescription, AttachmentLabel, AttachmentMedia, AttachmentProgress, AttachmentTitle } from "@/components/ui/attachment"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { BottomBar, BottomBarContent } from "@/components/ui/bottom-bar"
import { Bubble, BubbleContent, BubbleReactions } from "@/components/ui/bubble"
import { Icon } from "@/components/ui/icon"
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group"
import { Message, MessageAvatar, MessageContent, MessageFooter, MessageHeader } from "@/components/ui/message"
import { MessageScroller, MessageScrollerButton, MessageScrollerItem, MessageScrollerProvider, MessageScrollerViewport, useMessageScroller } from "@/components/ui/message-scroller"
import { Text } from "@/components/ui/text"

type ChatMessage = {
  id: string
  from: "me" | "them"
  texts: string[]
  time?: string
  status?: string
  reaction?: string
  attachment?: { name: string; size: string; progress?: number }
}

const subjects = [
  "Did you see the latest build?",
  "The onboarding copy needs one more pass.",
  "I can take the release notes.",
  "QA found two small issues on Android.",
  "Both are fixed in the next build.",
  "Can we move the review to the afternoon?",
]

function longThread(count: number, offset = 0): ChatMessage[] {
  return Array.from({ length: count }, (_, index) => {
    const number = index + offset
    return {
      id: `m${number}`,
      from: number % 3 === 0 ? "me" : "them",
      texts: [subjects[number % subjects.length]],
    }
  })
}

function ChatRow({ message }: { message: ChatMessage }) {
  const mine = message.from === "me"
  return (
    <MessageScrollerItem>
      <Message align={mine ? "end" : "start"}>
        {mine ? null : (
          <MessageAvatar>
            <Avatar>
              <AvatarFallback>MK</AvatarFallback>
            </Avatar>
          </MessageAvatar>
        )}
        <MessageContent>
          {message.time && !mine ? (
            <MessageHeader>Maya · {message.time}</MessageHeader>
          ) : null}
          {message.attachment ? (
            <Attachment
              state={message.attachment.progress ? "uploading" : "done"}
              className="w-64"
            >
              <AttachmentMedia variant="document">
                <AttachmentLabel className="bg-red-500">pdf</AttachmentLabel>
              </AttachmentMedia>
              <AttachmentContent>
                <AttachmentTitle>{message.attachment.name}</AttachmentTitle>
                {message.attachment.progress ? (
                  <AttachmentProgress
                    value={message.attachment.progress}
                    accessibilityLabel="Uploading"
                  />
                ) : (
                  <AttachmentDescription>
                    {message.attachment.size}
                  </AttachmentDescription>
                )}
              </AttachmentContent>
              {message.attachment.progress ? (
                <AttachmentActions>
                  <AttachmentAction accessibilityLabel="Cancel upload">
                    <Icon as={XIcon} />
                  </AttachmentAction>
                </AttachmentActions>
              ) : null}
            </Attachment>
          ) : null}
          {message.texts.map((text, index) => (
            <Bubble key={text} variant={mine ? "default" : "muted"}>
              <BubbleContent>{text}</BubbleContent>
              {message.reaction && index === message.texts.length - 1 ? (
                <BubbleReactions align={mine ? "start" : "end"}>
                  {message.reaction}
                </BubbleReactions>
              ) : null}
            </Bubble>
          ))}
          {message.status ? (
            <MessageFooter className="gap-1">
              <Icon as={ChecksIcon} className="text-primary" />
              <Text>{message.status}</Text>
            </MessageFooter>
          ) : null}
        </MessageContent>
      </Message>
    </MessageScrollerItem>
  )
}

function Composer({ onSend }: { onSend: (text: string) => void }) {
  const [value, setValue] = React.useState("")
  const { scrollToEnd } = useMessageScroller()
  const send = () => {
    const text = value.trim()
    if (!text) return
    onSend(text)
    setValue("")
    requestAnimationFrame(() => scrollToEnd())
  }
  return (
    <BottomBar separator>
      <BottomBarContent>
        <InputGroup>
          <InputGroupInput
            placeholder="Message"
            value={value}
            onValueChange={setValue}
            returnKeyType="send"
            onSubmitEditing={send}
          />
          <InputGroupAddon align="inline-end">
            <InputGroupButton
              variant="default"
              size="icon-xs"
              accessibilityLabel="Send"
              disabled={!value.trim()}
              onPress={send}
            >
              <Icon as={ArrowUpIcon} />
            </InputGroupButton>
          </InputGroupAddon>
        </InputGroup>
      </BottomBarContent>
    </BottomBar>
  )
}

type ChatReady = (
  append: (messages: ChatMessage[]) => void,
  scroller: ReturnType<typeof useMessageScroller>
) => void

function ReadyEffect({
  append,
  onReady,
}: {
  append: (messages: ChatMessage[]) => void
  onReady?: ChatReady
}) {
  const scroller = useMessageScroller()
  const readyRef = React.useRef(onReady)
  const scrollerRef = React.useRef(scroller)

  React.useEffect(() => {
    readyRef.current?.(append, scrollerRef.current)
  }, [append])

  return null
}

function Chat({
  initial,
  empty,
  onReady,
}: {
  initial: ChatMessage[]
  empty?: React.ReactElement
  onReady?: ChatReady
}) {
  const [messages, setMessages] = React.useState(initial)
  const append = React.useCallback((next: ChatMessage[]) => {
    setMessages((current) => [...current, ...next])
  }, [])

  return (
    <MessageScrollerProvider>
      <ReadyEffect append={append} onReady={onReady} />
      <View className="-m-4 flex-1">
        <MessageScroller>
          <MessageScrollerViewport
            data={messages}
            keyExtractor={(message) => message.id}
            renderItem={({ item }) => <ChatRow message={item} />}
            contentContainerClassName="gap-4 py-4"
            ListEmptyComponent={empty}
          />
          <MessageScrollerButton />
        </MessageScroller>
        <Composer
          onSend={(text) =>
            append([{ id: `sent-${Date.now()}`, from: "me", texts: [text] }])
          }
        />
      </View>
    </MessageScrollerProvider>
  )
}

export function MessageScrollerUnreadWhileScrolledUpExample() {
  return (
    <Chat
      initial={longThread(40)}
      onReady={(append, scroller) => {
        setTimeout(() => scroller.scrollToStart({ animated: false }), 300)
        setTimeout(
          () =>
            append([
              { id: "n1", from: "them", texts: ["Are you there?"] },
              { id: "n2", from: "them", texts: ["The build is green."] },
              { id: "n3", from: "them", texts: ["Merging now."] },
            ]),
          900
        )
      }}
    />
  )
}
import EsuissUI
import PhosphorSwift
import SwiftUI

struct MessageScrollerUnreadWhileScrolledUpExample: View {
  var body: some View {
    MessageScrollerChat(initial: MessageScrollerData.long(40), unreadDemo: true)
  }
}

struct MessageScrollerAttachment: Hashable {
  let name: String
  let size: String
  var progress: Double?
}

struct MessageScrollerMessage: Identifiable, Hashable {
  let id: String
  let fromMe: Bool
  let texts: [String]
  var time: String?
  var status: String?
  var reaction: String?
  var attachment: MessageScrollerAttachment?
}

enum MessageScrollerData {
  static let thread: [MessageScrollerMessage] = [
    MessageScrollerMessage(
      id: "1",
      fromMe: false,
      texts: ["Hey, do you have a minute?", "The client moved the launch."],
      time: "9:12 AM"
    ),
    MessageScrollerMessage(id: "2", fromMe: true, texts: ["Sure, what's up?"]),
    MessageScrollerMessage(
      id: "3",
      fromMe: false,
      texts: ["It's Thursday now. Two days earlier than planned."],
      reaction: "😬 1"
    ),
    MessageScrollerMessage(
      id: "4",
      fromMe: true,
      texts: ["We could ship onboarding behind a flag.", "Turn it on next week once QA is done."],
      status: "Read 9:15 AM"
    ),
    MessageScrollerMessage(
      id: "5",
      fromMe: false,
      texts: ["Here are the release notes."],
      time: "9:18 AM",
      attachment: MessageScrollerAttachment(name: "Release notes.pdf", size: "1.2 MB")
    ),
    MessageScrollerMessage(
      id: "6",
      fromMe: true,
      texts: ["And the checklist with my comments."],
      attachment: MessageScrollerAttachment(name: "Checklist.pdf", size: "Uploading", progress: 64)
    ),
    MessageScrollerMessage(
      id: "7",
      fromMe: false,
      texts: ["Perfect. Let's sync tomorrow at ten."],
      reaction: "👍 2"
    ),
    MessageScrollerMessage(id: "8", fromMe: true, texts: ["Sounds good, see you then."], status: "Delivered"),
  ]

  static let subjects = [
    "Did you see the latest build?",
    "The onboarding copy needs one more pass.",
    "I can take the release notes.",
    "QA found two small issues on Android.",
    "Both are fixed in the next build.",
    "Can we move the review to the afternoon?",
  ]

  static func long(_ count: Int, offset: Int = 0) -> [MessageScrollerMessage] {
    (0..<count).map { index in
      let number = index + offset
      return MessageScrollerMessage(
        id: "m\(number)",
        fromMe: number % 3 == 0,
        texts: [subjects[number % subjects.count]]
      )
    }
  }
}

struct MessageScrollerRow: View {
  let message: MessageScrollerMessage

  var body: some View {
    let mine = message.fromMe
    Message(align: mine ? .end : .start) {
      if !mine {
        MessageAvatar {
          Avatar { AvatarFallback("MK") }
        }
      }
      MessageContent {
        if let time = message.time, !mine {
          MessageHeader(verbatim: "Maya · \(time)")
        }
        if let attachment = message.attachment {
          MessageScrollerAttachmentView(attachment: attachment)
        }
        ForEach(Array(message.texts.enumerated()), id: \.offset) { index, text in
          Bubble(variant: mine ? .default : .muted) {
            BubbleContent(verbatim: text)
            if let reaction = message.reaction, index == message.texts.count - 1 {
              BubbleReactions(verbatim: reaction, align: mine ? .start : .end)
            }
          }
        }
        if let status = message.status {
          MessageFooter {
            Ph.checks.regular
              .frame(width: 16, height: 16)
              .foregroundStyle(EsuissColor.primary)
            Text(verbatim: status)
          }
        }
      }
    }
  }
}

struct MessageScrollerAttachmentView: View {
  let attachment: MessageScrollerAttachment

  var body: some View {
    Attachment(state: attachment.progress == nil ? .done : .uploading) {
      AttachmentMedia(.document) {
        AttachmentLabel("pdf", tint: Palette.red500)
      }
      AttachmentContent {
        AttachmentTitle(verbatim: attachment.name)
        if let progress = attachment.progress {
          AttachmentProgress(value: progress, label: "Uploading")
        } else {
          AttachmentDescription(verbatim: attachment.size)
        }
      }
      if attachment.progress != nil {
        AttachmentActions {
          AttachmentAction("Cancel upload", icon: Ph.x.bold) {}
        }
      }
    }
    .frame(width: 256)
  }
}

struct MessageScrollerChat: View {
  let unreadDemo: Bool

  @State private var messages: [MessageScrollerMessage]
  @State private var controller = MessageScrollerController()
  @State private var draft = ""

  init(initial: [MessageScrollerMessage], unreadDemo: Bool = false) {
    self.unreadDemo = unreadDemo
    _messages = State(initialValue: initial)
  }

  var body: some View {
    let ready = !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
    VStack(spacing: 0) {
      MessageScroller(messages, spacing: 16, controller: controller) { message in
        MessageScrollerRow(message: message)
      }
      .overlay {
        if messages.isEmpty {
          Empty {
            EmptyHeader {
              EmptyMedia(.icon) { Ph.chatsCircle.regular }
              EmptyTitle("No messages yet")
              EmptyDescription("Say hello to start the conversation.")
            }
          }
        }
      }
      .frame(maxHeight: .infinity)
      BottomBar(separator: true) {
        BottomBarContent {
          InputGroup {
            InputGroupInput("Message", text: $draft)
              .submitLabel(.send)
              .onSubmit(send)
            InputGroupAddon(align: .inlineEnd) {
              InputGroupButton(variant: .default, size: .iconXs, action: send) {
                Ph.arrowUp.bold.frame(width: 14, height: 14)
              }
              .disabled(!ready)
              .accessibilityLabel(Text("Send"))
            }
          }
        }
      }
    }
    .task {
      guard unreadDemo else { return }
      try? await Task.sleep(for: .milliseconds(300))
      controller.scrollToStart(animated: false)
      try? await Task.sleep(for: .milliseconds(600))
      messages.append(contentsOf: [
        MessageScrollerMessage(id: "n1", fromMe: false, texts: ["Are you there?"]),
        MessageScrollerMessage(id: "n2", fromMe: false, texts: ["The build is green."]),
        MessageScrollerMessage(id: "n3", fromMe: false, texts: ["Merging now."]),
      ])
    }
  }

  func send() {
    let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
    guard !text.isEmpty else { return }
    messages.append(MessageScrollerMessage(id: "sent-\(UUID().uuidString)", fromMe: true, texts: [text]))
    draft = ""
    Task { @MainActor in
      controller.scrollToEnd()
    }
  }
}

let thread: [MessageScrollerMessage] = [
  MessageScrollerMessage(
    id: "1",
    fromMe: false,
    texts: ["Hey, do you have a minute?", "The client moved the launch."],
    time: "9:12 AM"
  ),
  MessageScrollerMessage(id: "2", fromMe: true, texts: ["Sure, what's up?"]),
  MessageScrollerMessage(
    id: "3",
    fromMe: false,
    texts: ["It's Thursday now. Two days earlier than planned."],
    reaction: "😬 1"
  ),
  MessageScrollerMessage(
    id: "4",
    fromMe: true,
    texts: ["We could ship onboarding behind a flag.", "Turn it on next week once QA is done."],
    status: "Read 9:15 AM"
  ),
  MessageScrollerMessage(
    id: "5",
    fromMe: false,
    texts: ["Here are the release notes."],
    time: "9:18 AM",
    attachment: MessageScrollerAttachment(name: "Release notes.pdf", size: "1.2 MB")
  ),
  MessageScrollerMessage(
    id: "6",
    fromMe: true,
    texts: ["And the checklist with my comments."],
    attachment: MessageScrollerAttachment(name: "Checklist.pdf", size: "Uploading", progress: 64)
  ),
  MessageScrollerMessage(
    id: "7",
    fromMe: false,
    texts: ["Perfect. Let's sync tomorrow at ten."],
    reaction: "👍 2"
  ),
  MessageScrollerMessage(id: "8", fromMe: true, texts: ["Sounds good, see you then."], status: "Delivered"),
]

let subjects = [
  "Did you see the latest build?",
  "The onboarding copy needs one more pass.",
  "I can take the release notes.",
  "QA found two small issues on Android.",
  "Both are fixed in the next build.",
  "Can we move the review to the afternoon?",
]

func long(_ count: Int, offset: Int = 0) -> [MessageScrollerMessage] {
  (0..<count).map { index in
    let number = index + offset
    return MessageScrollerMessage(
      id: "m\(number)",
      fromMe: number % 3 == 0,
      texts: [subjects[number % subjects.count]]
    )
  }
}
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
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.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.ArrowUp
import com.adamglin.phosphoricons.regular.ChatsCircle
import com.adamglin.phosphoricons.regular.Checks
import com.adamglin.phosphoricons.regular.X
import com.esuiss.ui.components.Attachment
import com.esuiss.ui.components.AttachmentAction
import com.esuiss.ui.components.AttachmentActions
import com.esuiss.ui.components.AttachmentContent
import com.esuiss.ui.components.AttachmentDescription
import com.esuiss.ui.components.AttachmentLabel
import com.esuiss.ui.components.AttachmentMedia
import com.esuiss.ui.components.AttachmentMediaVariant
import com.esuiss.ui.components.AttachmentProgress
import com.esuiss.ui.components.AttachmentState
import com.esuiss.ui.components.AttachmentTitle
import com.esuiss.ui.components.Avatar
import com.esuiss.ui.components.AvatarFallback
import com.esuiss.ui.components.BottomBar
import com.esuiss.ui.components.BottomBarContent
import com.esuiss.ui.components.Bubble
import com.esuiss.ui.components.BubbleAlign
import com.esuiss.ui.components.BubbleContent
import com.esuiss.ui.components.BubbleReactions
import com.esuiss.ui.components.BubbleVariant
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Empty
import com.esuiss.ui.components.EmptyDescription
import com.esuiss.ui.components.EmptyHeader
import com.esuiss.ui.components.EmptyMedia
import com.esuiss.ui.components.EmptyMediaVariant
import com.esuiss.ui.components.EmptyTitle
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.InputGroup
import com.esuiss.ui.components.InputGroupAddon
import com.esuiss.ui.components.InputGroupAddonAlign
import com.esuiss.ui.components.InputGroupButton
import com.esuiss.ui.components.InputGroupButtonSize
import com.esuiss.ui.components.InputGroupInput
import com.esuiss.ui.components.Message
import com.esuiss.ui.components.MessageAlign
import com.esuiss.ui.components.MessageAvatar
import com.esuiss.ui.components.MessageContent
import com.esuiss.ui.components.MessageFooter
import com.esuiss.ui.components.MessageHeader
import com.esuiss.ui.components.MessageScroller
import com.esuiss.ui.components.MessageScrollerState
import com.esuiss.ui.components.Screen
import com.esuiss.ui.components.ScreenEdge
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.rememberMessageScrollerState
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

class MessageScrollerFile(val name: String, val size: String, val progress: Float? = null)

class MessageScrollerMessage(
    val id: String,
    val mine: Boolean,
    val texts: List<String>,
    val time: String? = null,
    val status: String? = null,
    val reaction: String? = null,
    val attachment: MessageScrollerFile? = null,
)

val messageScrollerSubjects = listOf(
    "Did you see the latest build?",
    "The onboarding copy needs one more pass.",
    "I can take the release notes.",
    "QA found two small issues on Android.",
    "Both are fixed in the next build.",
    "Can we move the review to the afternoon?",
)

fun messageScrollerLongThread(count: Int, offset: Int = 0): List<MessageScrollerMessage> =
    List(count) { index ->
        val number = index + offset
        MessageScrollerMessage("m$number", number % 3 == 0, listOf(messageScrollerSubjects[number % messageScrollerSubjects.size]))
    }

@Composable
fun MessageScrollerRow(message: MessageScrollerMessage) {
    val colors = EsuissTheme.colors
    val mine = message.mine
    Message(Modifier.fillMaxWidth(), align = if (mine) MessageAlign.End else MessageAlign.Start) {
        if (!mine) {
            MessageAvatar {
                Avatar(fallback = { AvatarFallback("MK") })
            }
        }
        MessageContent {
            if (message.time != null && !mine) MessageHeader("Maya · ${message.time}")
            val file = message.attachment
            if (file != null) {
                Attachment(
                    Modifier.width(256.dp),
                    state = if (file.progress != null) AttachmentState.Uploading else AttachmentState.Done,
                ) {
                    AttachmentMedia(variant = AttachmentMediaVariant.Document) {
                        AttachmentLabel("pdf", background = Palette.red500)
                    }
                    AttachmentContent {
                        AttachmentTitle(file.name)
                        if (file.progress != null) {
                            AttachmentProgress(file.progress, contentDescription = "Uploading")
                        } else {
                            AttachmentDescription(file.size)
                        }
                    }
                    if (file.progress != null) {
                        AttachmentActions {
                            AttachmentAction(onClick = {}, contentDescription = "Cancel upload") {
                                Icon(PhosphorIcons.Regular.X, contentDescription = null)
                            }
                        }
                    }
                }
            }
            message.texts.forEachIndexed { index, text ->
                Bubble(variant = if (mine) BubbleVariant.Default else BubbleVariant.Muted) {
                    BubbleContent(text)
                    if (message.reaction != null && index == message.texts.lastIndex) {
                        BubbleReactions(message.reaction, align = if (mine) BubbleAlign.Start else BubbleAlign.End)
                    }
                }
            }
            if (message.status != null) {
                MessageFooter {
                    Icon(PhosphorIcons.Regular.Checks, contentDescription = null, tint = colors.primary)
                    Text(message.status)
                }
            }
        }
    }
}

@Composable
fun MessageScrollerComposer(state: MessageScrollerState, onSend: (String) -> Unit) {
    var value by rememberSaveable { mutableStateOf("") }
    val scope = rememberCoroutineScope()
    val send = {
        val text = value.trim()
        if (text.isNotEmpty()) {
            onSend(text)
            value = ""
            scope.launch {
                withFrameNanos { }
                state.scrollToEnd()
            }
        }
    }
    BottomBar(separator = true) {
        BottomBarContent {
            InputGroup {
                InputGroupInput(
                    value,
                    { value = it },
                    placeholder = "Message",
                    keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
                    keyboardActions = KeyboardActions(onSend = { send() }),
                )
                InputGroupAddon(align = InputGroupAddonAlign.InlineEnd) {
                    InputGroupButton(
                        onClick = { send() },
                        modifier = Modifier.semantics { contentDescription = "Send" },
                        size = InputGroupButtonSize.IconXs,
                        variant = ButtonVariant.Default,
                        enabled = value.isNotBlank(),
                    ) {
                        Icon(PhosphorIcons.Regular.ArrowUp, contentDescription = null)
                    }
                }
            }
        }
    }
}

@Composable
fun MessageScrollerEmpty(modifier: Modifier) {
    Empty(modifier) {
        EmptyHeader {
            EmptyMedia(variant = EmptyMediaVariant.Icon) {
                Icon(PhosphorIcons.Regular.ChatsCircle, contentDescription = null)
            }
            EmptyTitle("No messages yet")
            EmptyDescription("Say hello to start the conversation.")
        }
    }
}

@Composable
fun MessageScrollerFrame(
    edges: Set<ScreenEdge> = setOf(ScreenEdge.Top, ScreenEdge.Left, ScreenEdge.Right),
    content: @Composable ColumnScope.() -> Unit,
) {
    Screen(edges = edges) {
        Column(Modifier.fillMaxSize()) {
            content()
        }
    }
}

@Composable
fun MessageScrollerList(
    messages: List<MessageScrollerMessage>,
    state: MessageScrollerState,
    modifier: Modifier,
    onLoadOlder: (() -> Unit)? = null,
    loadingOlder: Boolean = false,
) {
    MessageScroller(
        items = messages,
        key = { it.id },
        modifier = modifier,
        state = state,
        onLoadOlder = onLoadOlder,
        loadingOlder = loadingOlder,
        contentPadding = PaddingValues(16.dp),
        verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Bottom),
    ) { message ->
        MessageScrollerRow(message)
    }
}

@Composable
fun MessageScrollerChat(
    initial: List<MessageScrollerMessage>,
    showEmpty: Boolean = false,
    onReady: (suspend (append: (List<MessageScrollerMessage>) -> Unit, state: MessageScrollerState) -> Unit)? = null,
) {
    var messages by remember { mutableStateOf(initial) }
    val state = rememberMessageScrollerState()
    val append: (List<MessageScrollerMessage>) -> Unit = { next -> messages = messages + next }
    if (onReady != null) {
        LaunchedEffect(Unit) { onReady(append, state) }
    }
    MessageScrollerFrame {
        Box(Modifier.weight(1f).fillMaxWidth()) {
            MessageScrollerList(messages, state, Modifier.fillMaxSize())
            if (showEmpty && messages.isEmpty()) {
                MessageScrollerEmpty(Modifier.align(Alignment.Center))
            }
        }
        MessageScrollerComposer(state) { text ->
            append(listOf(MessageScrollerMessage("sent-${System.currentTimeMillis()}", true, listOf(text))))
        }
    }
}

@Composable
fun MessageScrollerUnread() {
    MessageScrollerChat(messageScrollerLongThread(40)) { append, state ->
        delay(300)
        state.scrollToStart(animated = false)
        delay(600)
        append(
            listOf(
                MessageScrollerMessage("n1", false, listOf("Are you there?")),
                MessageScrollerMessage("n2", false, listOf("The build is green.")),
                MessageScrollerMessage("n3", false, listOf("Merging now.")),
            ),
        )
    }
}

Empty thread

An empty thread that renders an Empty state through ListEmptyComponent.

iOS
Message Scroller (empty thread) on iOS in light modeMessage Scroller (empty thread) on iOS in dark mode
import { ArrowUpIcon, ChatsCircleIcon, ChecksIcon, XIcon } from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import { Attachment, AttachmentAction, AttachmentActions, AttachmentContent, AttachmentDescription, AttachmentLabel, AttachmentMedia, AttachmentProgress, AttachmentTitle } from "@/components/ui/attachment"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { BottomBar, BottomBarContent } from "@/components/ui/bottom-bar"
import { Bubble, BubbleContent, BubbleReactions } from "@/components/ui/bubble"
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
import { Icon } from "@/components/ui/icon"
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group"
import { Message, MessageAvatar, MessageContent, MessageFooter, MessageHeader } from "@/components/ui/message"
import { MessageScroller, MessageScrollerButton, MessageScrollerItem, MessageScrollerProvider, MessageScrollerViewport, useMessageScroller } from "@/components/ui/message-scroller"
import { Text } from "@/components/ui/text"

type ChatMessage = {
  id: string
  from: "me" | "them"
  texts: string[]
  time?: string
  status?: string
  reaction?: string
  attachment?: { name: string; size: string; progress?: number }
}

function ChatRow({ message }: { message: ChatMessage }) {
  const mine = message.from === "me"
  return (
    <MessageScrollerItem>
      <Message align={mine ? "end" : "start"}>
        {mine ? null : (
          <MessageAvatar>
            <Avatar>
              <AvatarFallback>MK</AvatarFallback>
            </Avatar>
          </MessageAvatar>
        )}
        <MessageContent>
          {message.time && !mine ? (
            <MessageHeader>Maya · {message.time}</MessageHeader>
          ) : null}
          {message.attachment ? (
            <Attachment
              state={message.attachment.progress ? "uploading" : "done"}
              className="w-64"
            >
              <AttachmentMedia variant="document">
                <AttachmentLabel className="bg-red-500">pdf</AttachmentLabel>
              </AttachmentMedia>
              <AttachmentContent>
                <AttachmentTitle>{message.attachment.name}</AttachmentTitle>
                {message.attachment.progress ? (
                  <AttachmentProgress
                    value={message.attachment.progress}
                    accessibilityLabel="Uploading"
                  />
                ) : (
                  <AttachmentDescription>
                    {message.attachment.size}
                  </AttachmentDescription>
                )}
              </AttachmentContent>
              {message.attachment.progress ? (
                <AttachmentActions>
                  <AttachmentAction accessibilityLabel="Cancel upload">
                    <Icon as={XIcon} />
                  </AttachmentAction>
                </AttachmentActions>
              ) : null}
            </Attachment>
          ) : null}
          {message.texts.map((text, index) => (
            <Bubble key={text} variant={mine ? "default" : "muted"}>
              <BubbleContent>{text}</BubbleContent>
              {message.reaction && index === message.texts.length - 1 ? (
                <BubbleReactions align={mine ? "start" : "end"}>
                  {message.reaction}
                </BubbleReactions>
              ) : null}
            </Bubble>
          ))}
          {message.status ? (
            <MessageFooter className="gap-1">
              <Icon as={ChecksIcon} className="text-primary" />
              <Text>{message.status}</Text>
            </MessageFooter>
          ) : null}
        </MessageContent>
      </Message>
    </MessageScrollerItem>
  )
}

function Composer({ onSend }: { onSend: (text: string) => void }) {
  const [value, setValue] = React.useState("")
  const { scrollToEnd } = useMessageScroller()
  const send = () => {
    const text = value.trim()
    if (!text) return
    onSend(text)
    setValue("")
    requestAnimationFrame(() => scrollToEnd())
  }
  return (
    <BottomBar separator>
      <BottomBarContent>
        <InputGroup>
          <InputGroupInput
            placeholder="Message"
            value={value}
            onValueChange={setValue}
            returnKeyType="send"
            onSubmitEditing={send}
          />
          <InputGroupAddon align="inline-end">
            <InputGroupButton
              variant="default"
              size="icon-xs"
              accessibilityLabel="Send"
              disabled={!value.trim()}
              onPress={send}
            >
              <Icon as={ArrowUpIcon} />
            </InputGroupButton>
          </InputGroupAddon>
        </InputGroup>
      </BottomBarContent>
    </BottomBar>
  )
}

type ChatReady = (
  append: (messages: ChatMessage[]) => void,
  scroller: ReturnType<typeof useMessageScroller>
) => void

function ReadyEffect({
  append,
  onReady,
}: {
  append: (messages: ChatMessage[]) => void
  onReady?: ChatReady
}) {
  const scroller = useMessageScroller()
  const readyRef = React.useRef(onReady)
  const scrollerRef = React.useRef(scroller)

  React.useEffect(() => {
    readyRef.current?.(append, scrollerRef.current)
  }, [append])

  return null
}

function Chat({
  initial,
  empty,
  onReady,
}: {
  initial: ChatMessage[]
  empty?: React.ReactElement
  onReady?: ChatReady
}) {
  const [messages, setMessages] = React.useState(initial)
  const append = React.useCallback((next: ChatMessage[]) => {
    setMessages((current) => [...current, ...next])
  }, [])

  return (
    <MessageScrollerProvider>
      <ReadyEffect append={append} onReady={onReady} />
      <View className="-m-4 flex-1">
        <MessageScroller>
          <MessageScrollerViewport
            data={messages}
            keyExtractor={(message) => message.id}
            renderItem={({ item }) => <ChatRow message={item} />}
            contentContainerClassName="gap-4 py-4"
            ListEmptyComponent={empty}
          />
          <MessageScrollerButton />
        </MessageScroller>
        <Composer
          onSend={(text) =>
            append([{ id: `sent-${Date.now()}`, from: "me", texts: [text] }])
          }
        />
      </View>
    </MessageScrollerProvider>
  )
}

export function MessageScrollerEmptyThreadExample() {
  return (
    <Chat
      initial={[]}
      empty={
        <Empty>
          <EmptyHeader>
            <EmptyMedia variant="icon">
              <Icon as={ChatsCircleIcon} />
            </EmptyMedia>
            <EmptyTitle>No messages yet</EmptyTitle>
            <EmptyDescription>
              Say hello to start the conversation.
            </EmptyDescription>
          </EmptyHeader>
        </Empty>
      }
    />
  )
}
import EsuissUI
import PhosphorSwift
import SwiftUI

struct MessageScrollerEmptyThreadExample: View {
  var body: some View {
    MessageScrollerChat(initial: [])
  }
}

struct MessageScrollerAttachment: Hashable {
  let name: String
  let size: String
  var progress: Double?
}

struct MessageScrollerMessage: Identifiable, Hashable {
  let id: String
  let fromMe: Bool
  let texts: [String]
  var time: String?
  var status: String?
  var reaction: String?
  var attachment: MessageScrollerAttachment?
}

struct MessageScrollerRow: View {
  let message: MessageScrollerMessage

  var body: some View {
    let mine = message.fromMe
    Message(align: mine ? .end : .start) {
      if !mine {
        MessageAvatar {
          Avatar { AvatarFallback("MK") }
        }
      }
      MessageContent {
        if let time = message.time, !mine {
          MessageHeader(verbatim: "Maya · \(time)")
        }
        if let attachment = message.attachment {
          MessageScrollerAttachmentView(attachment: attachment)
        }
        ForEach(Array(message.texts.enumerated()), id: \.offset) { index, text in
          Bubble(variant: mine ? .default : .muted) {
            BubbleContent(verbatim: text)
            if let reaction = message.reaction, index == message.texts.count - 1 {
              BubbleReactions(verbatim: reaction, align: mine ? .start : .end)
            }
          }
        }
        if let status = message.status {
          MessageFooter {
            Ph.checks.regular
              .frame(width: 16, height: 16)
              .foregroundStyle(EsuissColor.primary)
            Text(verbatim: status)
          }
        }
      }
    }
  }
}

struct MessageScrollerAttachmentView: View {
  let attachment: MessageScrollerAttachment

  var body: some View {
    Attachment(state: attachment.progress == nil ? .done : .uploading) {
      AttachmentMedia(.document) {
        AttachmentLabel("pdf", tint: Palette.red500)
      }
      AttachmentContent {
        AttachmentTitle(verbatim: attachment.name)
        if let progress = attachment.progress {
          AttachmentProgress(value: progress, label: "Uploading")
        } else {
          AttachmentDescription(verbatim: attachment.size)
        }
      }
      if attachment.progress != nil {
        AttachmentActions {
          AttachmentAction("Cancel upload", icon: Ph.x.bold) {}
        }
      }
    }
    .frame(width: 256)
  }
}

struct MessageScrollerChat: View {
  let unreadDemo: Bool

  @State private var messages: [MessageScrollerMessage]
  @State private var controller = MessageScrollerController()
  @State private var draft = ""

  init(initial: [MessageScrollerMessage], unreadDemo: Bool = false) {
    self.unreadDemo = unreadDemo
    _messages = State(initialValue: initial)
  }

  var body: some View {
    let ready = !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
    VStack(spacing: 0) {
      MessageScroller(messages, spacing: 16, controller: controller) { message in
        MessageScrollerRow(message: message)
      }
      .overlay {
        if messages.isEmpty {
          Empty {
            EmptyHeader {
              EmptyMedia(.icon) { Ph.chatsCircle.regular }
              EmptyTitle("No messages yet")
              EmptyDescription("Say hello to start the conversation.")
            }
          }
        }
      }
      .frame(maxHeight: .infinity)
      BottomBar(separator: true) {
        BottomBarContent {
          InputGroup {
            InputGroupInput("Message", text: $draft)
              .submitLabel(.send)
              .onSubmit(send)
            InputGroupAddon(align: .inlineEnd) {
              InputGroupButton(variant: .default, size: .iconXs, action: send) {
                Ph.arrowUp.bold.frame(width: 14, height: 14)
              }
              .disabled(!ready)
              .accessibilityLabel(Text("Send"))
            }
          }
        }
      }
    }
    .task {
      guard unreadDemo else { return }
      try? await Task.sleep(for: .milliseconds(300))
      controller.scrollToStart(animated: false)
      try? await Task.sleep(for: .milliseconds(600))
      messages.append(contentsOf: [
        MessageScrollerMessage(id: "n1", fromMe: false, texts: ["Are you there?"]),
        MessageScrollerMessage(id: "n2", fromMe: false, texts: ["The build is green."]),
        MessageScrollerMessage(id: "n3", fromMe: false, texts: ["Merging now."]),
      ])
    }
  }

  func send() {
    let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
    guard !text.isEmpty else { return }
    messages.append(MessageScrollerMessage(id: "sent-\(UUID().uuidString)", fromMe: true, texts: [text]))
    draft = ""
    Task { @MainActor in
      controller.scrollToEnd()
    }
  }
}
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
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.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.ArrowUp
import com.adamglin.phosphoricons.regular.ChatsCircle
import com.adamglin.phosphoricons.regular.Checks
import com.adamglin.phosphoricons.regular.X
import com.esuiss.ui.components.Attachment
import com.esuiss.ui.components.AttachmentAction
import com.esuiss.ui.components.AttachmentActions
import com.esuiss.ui.components.AttachmentContent
import com.esuiss.ui.components.AttachmentDescription
import com.esuiss.ui.components.AttachmentLabel
import com.esuiss.ui.components.AttachmentMedia
import com.esuiss.ui.components.AttachmentMediaVariant
import com.esuiss.ui.components.AttachmentProgress
import com.esuiss.ui.components.AttachmentState
import com.esuiss.ui.components.AttachmentTitle
import com.esuiss.ui.components.Avatar
import com.esuiss.ui.components.AvatarFallback
import com.esuiss.ui.components.BottomBar
import com.esuiss.ui.components.BottomBarContent
import com.esuiss.ui.components.Bubble
import com.esuiss.ui.components.BubbleAlign
import com.esuiss.ui.components.BubbleContent
import com.esuiss.ui.components.BubbleReactions
import com.esuiss.ui.components.BubbleVariant
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Empty
import com.esuiss.ui.components.EmptyDescription
import com.esuiss.ui.components.EmptyHeader
import com.esuiss.ui.components.EmptyMedia
import com.esuiss.ui.components.EmptyMediaVariant
import com.esuiss.ui.components.EmptyTitle
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.InputGroup
import com.esuiss.ui.components.InputGroupAddon
import com.esuiss.ui.components.InputGroupAddonAlign
import com.esuiss.ui.components.InputGroupButton
import com.esuiss.ui.components.InputGroupButtonSize
import com.esuiss.ui.components.InputGroupInput
import com.esuiss.ui.components.Message
import com.esuiss.ui.components.MessageAlign
import com.esuiss.ui.components.MessageAvatar
import com.esuiss.ui.components.MessageContent
import com.esuiss.ui.components.MessageFooter
import com.esuiss.ui.components.MessageHeader
import com.esuiss.ui.components.MessageScroller
import com.esuiss.ui.components.MessageScrollerState
import com.esuiss.ui.components.Screen
import com.esuiss.ui.components.ScreenEdge
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.rememberMessageScrollerState
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import kotlinx.coroutines.launch

@Composable
fun MessageScrollerEmptyThreadExample() {
    MessageScrollerChat(emptyList(), showEmpty = true)
}

class MessageScrollerFile(val name: String, val size: String, val progress: Float? = null)

class MessageScrollerMessage(
    val id: String,
    val mine: Boolean,
    val texts: List<String>,
    val time: String? = null,
    val status: String? = null,
    val reaction: String? = null,
    val attachment: MessageScrollerFile? = null,
)

@Composable
fun MessageScrollerRow(message: MessageScrollerMessage) {
    val colors = EsuissTheme.colors
    val mine = message.mine
    Message(Modifier.fillMaxWidth(), align = if (mine) MessageAlign.End else MessageAlign.Start) {
        if (!mine) {
            MessageAvatar {
                Avatar(fallback = { AvatarFallback("MK") })
            }
        }
        MessageContent {
            if (message.time != null && !mine) MessageHeader("Maya · ${message.time}")
            val file = message.attachment
            if (file != null) {
                Attachment(
                    Modifier.width(256.dp),
                    state = if (file.progress != null) AttachmentState.Uploading else AttachmentState.Done,
                ) {
                    AttachmentMedia(variant = AttachmentMediaVariant.Document) {
                        AttachmentLabel("pdf", background = Palette.red500)
                    }
                    AttachmentContent {
                        AttachmentTitle(file.name)
                        if (file.progress != null) {
                            AttachmentProgress(file.progress, contentDescription = "Uploading")
                        } else {
                            AttachmentDescription(file.size)
                        }
                    }
                    if (file.progress != null) {
                        AttachmentActions {
                            AttachmentAction(onClick = {}, contentDescription = "Cancel upload") {
                                Icon(PhosphorIcons.Regular.X, contentDescription = null)
                            }
                        }
                    }
                }
            }
            message.texts.forEachIndexed { index, text ->
                Bubble(variant = if (mine) BubbleVariant.Default else BubbleVariant.Muted) {
                    BubbleContent(text)
                    if (message.reaction != null && index == message.texts.lastIndex) {
                        BubbleReactions(message.reaction, align = if (mine) BubbleAlign.Start else BubbleAlign.End)
                    }
                }
            }
            if (message.status != null) {
                MessageFooter {
                    Icon(PhosphorIcons.Regular.Checks, contentDescription = null, tint = colors.primary)
                    Text(message.status)
                }
            }
        }
    }
}

@Composable
fun MessageScrollerComposer(state: MessageScrollerState, onSend: (String) -> Unit) {
    var value by rememberSaveable { mutableStateOf("") }
    val scope = rememberCoroutineScope()
    val send = {
        val text = value.trim()
        if (text.isNotEmpty()) {
            onSend(text)
            value = ""
            scope.launch {
                withFrameNanos { }
                state.scrollToEnd()
            }
        }
    }
    BottomBar(separator = true) {
        BottomBarContent {
            InputGroup {
                InputGroupInput(
                    value,
                    { value = it },
                    placeholder = "Message",
                    keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
                    keyboardActions = KeyboardActions(onSend = { send() }),
                )
                InputGroupAddon(align = InputGroupAddonAlign.InlineEnd) {
                    InputGroupButton(
                        onClick = { send() },
                        modifier = Modifier.semantics { contentDescription = "Send" },
                        size = InputGroupButtonSize.IconXs,
                        variant = ButtonVariant.Default,
                        enabled = value.isNotBlank(),
                    ) {
                        Icon(PhosphorIcons.Regular.ArrowUp, contentDescription = null)
                    }
                }
            }
        }
    }
}

@Composable
fun MessageScrollerEmpty(modifier: Modifier) {
    Empty(modifier) {
        EmptyHeader {
            EmptyMedia(variant = EmptyMediaVariant.Icon) {
                Icon(PhosphorIcons.Regular.ChatsCircle, contentDescription = null)
            }
            EmptyTitle("No messages yet")
            EmptyDescription("Say hello to start the conversation.")
        }
    }
}

@Composable
fun MessageScrollerFrame(
    edges: Set<ScreenEdge> = setOf(ScreenEdge.Top, ScreenEdge.Left, ScreenEdge.Right),
    content: @Composable ColumnScope.() -> Unit,
) {
    Screen(edges = edges) {
        Column(Modifier.fillMaxSize()) {
            content()
        }
    }
}

@Composable
fun MessageScrollerList(
    messages: List<MessageScrollerMessage>,
    state: MessageScrollerState,
    modifier: Modifier,
    onLoadOlder: (() -> Unit)? = null,
    loadingOlder: Boolean = false,
) {
    MessageScroller(
        items = messages,
        key = { it.id },
        modifier = modifier,
        state = state,
        onLoadOlder = onLoadOlder,
        loadingOlder = loadingOlder,
        contentPadding = PaddingValues(16.dp),
        verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Bottom),
    ) { message ->
        MessageScrollerRow(message)
    }
}

@Composable
fun MessageScrollerChat(
    initial: List<MessageScrollerMessage>,
    showEmpty: Boolean = false,
    onReady: (suspend (append: (List<MessageScrollerMessage>) -> Unit, state: MessageScrollerState) -> Unit)? = null,
) {
    var messages by remember { mutableStateOf(initial) }
    val state = rememberMessageScrollerState()
    val append: (List<MessageScrollerMessage>) -> Unit = { next -> messages = messages + next }
    if (onReady != null) {
        LaunchedEffect(Unit) { onReady(append, state) }
    }
    MessageScrollerFrame {
        Box(Modifier.weight(1f).fillMaxWidth()) {
            MessageScrollerList(messages, state, Modifier.fillMaxSize())
            if (showEmpty && messages.isEmpty()) {
                MessageScrollerEmpty(Modifier.align(Alignment.Center))
            }
        }
        MessageScrollerComposer(state) { text ->
            append(listOf(MessageScrollerMessage("sent-${System.currentTimeMillis()}", true, listOf(text))))
        }
    }
}

Load older

Loads earlier messages with onLoadOlder when scrolling to the top.

iOS
Message Scroller (load older) on iOS in light modeMessage Scroller (load older) on iOS in dark mode
import { ChecksIcon, XIcon } from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import { Attachment, AttachmentAction, AttachmentActions, AttachmentContent, AttachmentDescription, AttachmentLabel, AttachmentMedia, AttachmentProgress, AttachmentTitle } from "@/components/ui/attachment"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Bubble, BubbleContent, BubbleReactions } from "@/components/ui/bubble"
import { Icon } from "@/components/ui/icon"
import { Message, MessageAvatar, MessageContent, MessageFooter, MessageHeader } from "@/components/ui/message"
import { MessageScroller, MessageScrollerButton, MessageScrollerItem, MessageScrollerProvider, MessageScrollerViewport } from "@/components/ui/message-scroller"
import { Spinner } from "@/components/ui/spinner"
import { Text } from "@/components/ui/text"

type ChatMessage = {
  id: string
  from: "me" | "them"
  texts: string[]
  time?: string
  status?: string
  reaction?: string
  attachment?: { name: string; size: string; progress?: number }
}

const subjects = [
  "Did you see the latest build?",
  "The onboarding copy needs one more pass.",
  "I can take the release notes.",
  "QA found two small issues on Android.",
  "Both are fixed in the next build.",
  "Can we move the review to the afternoon?",
]

function longThread(count: number, offset = 0): ChatMessage[] {
  return Array.from({ length: count }, (_, index) => {
    const number = index + offset
    return {
      id: `m${number}`,
      from: number % 3 === 0 ? "me" : "them",
      texts: [subjects[number % subjects.length]],
    }
  })
}

function ChatRow({ message }: { message: ChatMessage }) {
  const mine = message.from === "me"
  return (
    <MessageScrollerItem>
      <Message align={mine ? "end" : "start"}>
        {mine ? null : (
          <MessageAvatar>
            <Avatar>
              <AvatarFallback>MK</AvatarFallback>
            </Avatar>
          </MessageAvatar>
        )}
        <MessageContent>
          {message.time && !mine ? (
            <MessageHeader>Maya · {message.time}</MessageHeader>
          ) : null}
          {message.attachment ? (
            <Attachment
              state={message.attachment.progress ? "uploading" : "done"}
              className="w-64"
            >
              <AttachmentMedia variant="document">
                <AttachmentLabel className="bg-red-500">pdf</AttachmentLabel>
              </AttachmentMedia>
              <AttachmentContent>
                <AttachmentTitle>{message.attachment.name}</AttachmentTitle>
                {message.attachment.progress ? (
                  <AttachmentProgress
                    value={message.attachment.progress}
                    accessibilityLabel="Uploading"
                  />
                ) : (
                  <AttachmentDescription>
                    {message.attachment.size}
                  </AttachmentDescription>
                )}
              </AttachmentContent>
              {message.attachment.progress ? (
                <AttachmentActions>
                  <AttachmentAction accessibilityLabel="Cancel upload">
                    <Icon as={XIcon} />
                  </AttachmentAction>
                </AttachmentActions>
              ) : null}
            </Attachment>
          ) : null}
          {message.texts.map((text, index) => (
            <Bubble key={text} variant={mine ? "default" : "muted"}>
              <BubbleContent>{text}</BubbleContent>
              {message.reaction && index === message.texts.length - 1 ? (
                <BubbleReactions align={mine ? "start" : "end"}>
                  {message.reaction}
                </BubbleReactions>
              ) : null}
            </Bubble>
          ))}
          {message.status ? (
            <MessageFooter className="gap-1">
              <Icon as={ChecksIcon} className="text-primary" />
              <Text>{message.status}</Text>
            </MessageFooter>
          ) : null}
        </MessageContent>
      </Message>
    </MessageScrollerItem>
  )
}

function LoadOlderDemo() {
  const [messages, setMessages] = React.useState(() => longThread(15, 45))
  const [loading, setLoading] = React.useState(false)
  const oldest = Number(messages[0]?.id.slice(1) ?? 0)
  const loadOlder = () => {
    if (loading || oldest <= 0) return
    setLoading(true)
    setTimeout(() => {
      setMessages((current) => [
        ...longThread(Math.min(15, oldest), Math.max(0, oldest - 15)),
        ...current,
      ])
      setLoading(false)
    }, 800)
  }
  return (
    <MessageScrollerProvider>
      <View className="-m-4 flex-1">
        <MessageScroller>
          <MessageScrollerViewport
            data={messages}
            keyExtractor={(message) => message.id}
            renderItem={({ item }) => <ChatRow message={item} />}
            contentContainerClassName="gap-4 py-4"
            onLoadOlder={loadOlder}
            ListFooterComponent={
              loading ? (
                <View className="items-center py-2">
                  <Spinner />
                </View>
              ) : null
            }
          />
          <MessageScrollerButton />
        </MessageScroller>
      </View>
    </MessageScrollerProvider>
  )
}

export function MessageScrollerLoadOlderExample() {
  return (
    <LoadOlderDemo />
  )
}
import EsuissUI
import PhosphorSwift
import SwiftUI

struct MessageScrollerAttachment: Hashable {
  let name: String
  let size: String
  var progress: Double?
}

struct MessageScrollerMessage: Identifiable, Hashable {
  let id: String
  let fromMe: Bool
  let texts: [String]
  var time: String?
  var status: String?
  var reaction: String?
  var attachment: MessageScrollerAttachment?
}

enum MessageScrollerData {
  static let thread: [MessageScrollerMessage] = [
    MessageScrollerMessage(
      id: "1",
      fromMe: false,
      texts: ["Hey, do you have a minute?", "The client moved the launch."],
      time: "9:12 AM"
    ),
    MessageScrollerMessage(id: "2", fromMe: true, texts: ["Sure, what's up?"]),
    MessageScrollerMessage(
      id: "3",
      fromMe: false,
      texts: ["It's Thursday now. Two days earlier than planned."],
      reaction: "😬 1"
    ),
    MessageScrollerMessage(
      id: "4",
      fromMe: true,
      texts: ["We could ship onboarding behind a flag.", "Turn it on next week once QA is done."],
      status: "Read 9:15 AM"
    ),
    MessageScrollerMessage(
      id: "5",
      fromMe: false,
      texts: ["Here are the release notes."],
      time: "9:18 AM",
      attachment: MessageScrollerAttachment(name: "Release notes.pdf", size: "1.2 MB")
    ),
    MessageScrollerMessage(
      id: "6",
      fromMe: true,
      texts: ["And the checklist with my comments."],
      attachment: MessageScrollerAttachment(name: "Checklist.pdf", size: "Uploading", progress: 64)
    ),
    MessageScrollerMessage(
      id: "7",
      fromMe: false,
      texts: ["Perfect. Let's sync tomorrow at ten."],
      reaction: "👍 2"
    ),
    MessageScrollerMessage(id: "8", fromMe: true, texts: ["Sounds good, see you then."], status: "Delivered"),
  ]

  static let subjects = [
    "Did you see the latest build?",
    "The onboarding copy needs one more pass.",
    "I can take the release notes.",
    "QA found two small issues on Android.",
    "Both are fixed in the next build.",
    "Can we move the review to the afternoon?",
  ]

  static func long(_ count: Int, offset: Int = 0) -> [MessageScrollerMessage] {
    (0..<count).map { index in
      let number = index + offset
      return MessageScrollerMessage(
        id: "m\(number)",
        fromMe: number % 3 == 0,
        texts: [subjects[number % subjects.count]]
      )
    }
  }
}

struct MessageScrollerRow: View {
  let message: MessageScrollerMessage

  var body: some View {
    let mine = message.fromMe
    Message(align: mine ? .end : .start) {
      if !mine {
        MessageAvatar {
          Avatar { AvatarFallback("MK") }
        }
      }
      MessageContent {
        if let time = message.time, !mine {
          MessageHeader(verbatim: "Maya · \(time)")
        }
        if let attachment = message.attachment {
          MessageScrollerAttachmentView(attachment: attachment)
        }
        ForEach(Array(message.texts.enumerated()), id: \.offset) { index, text in
          Bubble(variant: mine ? .default : .muted) {
            BubbleContent(verbatim: text)
            if let reaction = message.reaction, index == message.texts.count - 1 {
              BubbleReactions(verbatim: reaction, align: mine ? .start : .end)
            }
          }
        }
        if let status = message.status {
          MessageFooter {
            Ph.checks.regular
              .frame(width: 16, height: 16)
              .foregroundStyle(EsuissColor.primary)
            Text(verbatim: status)
          }
        }
      }
    }
  }
}

struct MessageScrollerAttachmentView: View {
  let attachment: MessageScrollerAttachment

  var body: some View {
    Attachment(state: attachment.progress == nil ? .done : .uploading) {
      AttachmentMedia(.document) {
        AttachmentLabel("pdf", tint: Palette.red500)
      }
      AttachmentContent {
        AttachmentTitle(verbatim: attachment.name)
        if let progress = attachment.progress {
          AttachmentProgress(value: progress, label: "Uploading")
        } else {
          AttachmentDescription(verbatim: attachment.size)
        }
      }
      if attachment.progress != nil {
        AttachmentActions {
          AttachmentAction("Cancel upload", icon: Ph.x.bold) {}
        }
      }
    }
    .frame(width: 256)
  }
}

struct MessageScrollerLoadOlder: View {
  @State private var messages = MessageScrollerData.long(15, offset: 45)
  @State private var loading = false

  var body: some View {
    MessageScroller(messages, spacing: 16, onLoadOlder: loadOlder) { message in
      MessageScrollerRow(message: message)
    }
    .overlay(alignment: .top) {
      if loading {
        Spinner()
          .padding(.vertical, 8)
      }
    }
  }

  var oldest: Int {
    Int(messages.first?.id.dropFirst() ?? "") ?? 0
  }

  func loadOlder() {
    let oldest = oldest
    guard !loading, oldest > 0 else { return }
    loading = true
    Task {
      try? await Task.sleep(for: .milliseconds(800))
      messages.insert(
        contentsOf: MessageScrollerData.long(min(15, oldest), offset: max(0, oldest - 15)),
        at: 0
      )
      loading = false
    }
  }
}

let thread: [MessageScrollerMessage] = [
  MessageScrollerMessage(
    id: "1",
    fromMe: false,
    texts: ["Hey, do you have a minute?", "The client moved the launch."],
    time: "9:12 AM"
  ),
  MessageScrollerMessage(id: "2", fromMe: true, texts: ["Sure, what's up?"]),
  MessageScrollerMessage(
    id: "3",
    fromMe: false,
    texts: ["It's Thursday now. Two days earlier than planned."],
    reaction: "😬 1"
  ),
  MessageScrollerMessage(
    id: "4",
    fromMe: true,
    texts: ["We could ship onboarding behind a flag.", "Turn it on next week once QA is done."],
    status: "Read 9:15 AM"
  ),
  MessageScrollerMessage(
    id: "5",
    fromMe: false,
    texts: ["Here are the release notes."],
    time: "9:18 AM",
    attachment: MessageScrollerAttachment(name: "Release notes.pdf", size: "1.2 MB")
  ),
  MessageScrollerMessage(
    id: "6",
    fromMe: true,
    texts: ["And the checklist with my comments."],
    attachment: MessageScrollerAttachment(name: "Checklist.pdf", size: "Uploading", progress: 64)
  ),
  MessageScrollerMessage(
    id: "7",
    fromMe: false,
    texts: ["Perfect. Let's sync tomorrow at ten."],
    reaction: "👍 2"
  ),
  MessageScrollerMessage(id: "8", fromMe: true, texts: ["Sounds good, see you then."], status: "Delivered"),
]

let subjects = [
  "Did you see the latest build?",
  "The onboarding copy needs one more pass.",
  "I can take the release notes.",
  "QA found two small issues on Android.",
  "Both are fixed in the next build.",
  "Can we move the review to the afternoon?",
]

func long(_ count: Int, offset: Int = 0) -> [MessageScrollerMessage] {
  (0..<count).map { index in
    let number = index + offset
    return MessageScrollerMessage(
      id: "m\(number)",
      fromMe: number % 3 == 0,
      texts: [subjects[number % subjects.count]]
    )
  }
}
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.Checks
import com.adamglin.phosphoricons.regular.X
import com.esuiss.ui.components.Attachment
import com.esuiss.ui.components.AttachmentAction
import com.esuiss.ui.components.AttachmentActions
import com.esuiss.ui.components.AttachmentContent
import com.esuiss.ui.components.AttachmentDescription
import com.esuiss.ui.components.AttachmentLabel
import com.esuiss.ui.components.AttachmentMedia
import com.esuiss.ui.components.AttachmentMediaVariant
import com.esuiss.ui.components.AttachmentProgress
import com.esuiss.ui.components.AttachmentState
import com.esuiss.ui.components.AttachmentTitle
import com.esuiss.ui.components.Avatar
import com.esuiss.ui.components.AvatarFallback
import com.esuiss.ui.components.Bubble
import com.esuiss.ui.components.BubbleAlign
import com.esuiss.ui.components.BubbleContent
import com.esuiss.ui.components.BubbleReactions
import com.esuiss.ui.components.BubbleVariant
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.Message
import com.esuiss.ui.components.MessageAlign
import com.esuiss.ui.components.MessageAvatar
import com.esuiss.ui.components.MessageContent
import com.esuiss.ui.components.MessageFooter
import com.esuiss.ui.components.MessageHeader
import com.esuiss.ui.components.MessageScroller
import com.esuiss.ui.components.MessageScrollerState
import com.esuiss.ui.components.Screen
import com.esuiss.ui.components.ScreenEdge
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.rememberMessageScrollerState
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

class MessageScrollerFile(val name: String, val size: String, val progress: Float? = null)

class MessageScrollerMessage(
    val id: String,
    val mine: Boolean,
    val texts: List<String>,
    val time: String? = null,
    val status: String? = null,
    val reaction: String? = null,
    val attachment: MessageScrollerFile? = null,
)

val messageScrollerSubjects = listOf(
    "Did you see the latest build?",
    "The onboarding copy needs one more pass.",
    "I can take the release notes.",
    "QA found two small issues on Android.",
    "Both are fixed in the next build.",
    "Can we move the review to the afternoon?",
)

fun messageScrollerLongThread(count: Int, offset: Int = 0): List<MessageScrollerMessage> =
    List(count) { index ->
        val number = index + offset
        MessageScrollerMessage("m$number", number % 3 == 0, listOf(messageScrollerSubjects[number % messageScrollerSubjects.size]))
    }

@Composable
fun MessageScrollerRow(message: MessageScrollerMessage) {
    val colors = EsuissTheme.colors
    val mine = message.mine
    Message(Modifier.fillMaxWidth(), align = if (mine) MessageAlign.End else MessageAlign.Start) {
        if (!mine) {
            MessageAvatar {
                Avatar(fallback = { AvatarFallback("MK") })
            }
        }
        MessageContent {
            if (message.time != null && !mine) MessageHeader("Maya · ${message.time}")
            val file = message.attachment
            if (file != null) {
                Attachment(
                    Modifier.width(256.dp),
                    state = if (file.progress != null) AttachmentState.Uploading else AttachmentState.Done,
                ) {
                    AttachmentMedia(variant = AttachmentMediaVariant.Document) {
                        AttachmentLabel("pdf", background = Palette.red500)
                    }
                    AttachmentContent {
                        AttachmentTitle(file.name)
                        if (file.progress != null) {
                            AttachmentProgress(file.progress, contentDescription = "Uploading")
                        } else {
                            AttachmentDescription(file.size)
                        }
                    }
                    if (file.progress != null) {
                        AttachmentActions {
                            AttachmentAction(onClick = {}, contentDescription = "Cancel upload") {
                                Icon(PhosphorIcons.Regular.X, contentDescription = null)
                            }
                        }
                    }
                }
            }
            message.texts.forEachIndexed { index, text ->
                Bubble(variant = if (mine) BubbleVariant.Default else BubbleVariant.Muted) {
                    BubbleContent(text)
                    if (message.reaction != null && index == message.texts.lastIndex) {
                        BubbleReactions(message.reaction, align = if (mine) BubbleAlign.Start else BubbleAlign.End)
                    }
                }
            }
            if (message.status != null) {
                MessageFooter {
                    Icon(PhosphorIcons.Regular.Checks, contentDescription = null, tint = colors.primary)
                    Text(message.status)
                }
            }
        }
    }
}

@Composable
fun MessageScrollerFrame(
    edges: Set<ScreenEdge> = setOf(ScreenEdge.Top, ScreenEdge.Left, ScreenEdge.Right),
    content: @Composable ColumnScope.() -> Unit,
) {
    Screen(edges = edges) {
        Column(Modifier.fillMaxSize()) {
            content()
        }
    }
}

@Composable
fun MessageScrollerList(
    messages: List<MessageScrollerMessage>,
    state: MessageScrollerState,
    modifier: Modifier,
    onLoadOlder: (() -> Unit)? = null,
    loadingOlder: Boolean = false,
) {
    MessageScroller(
        items = messages,
        key = { it.id },
        modifier = modifier,
        state = state,
        onLoadOlder = onLoadOlder,
        loadingOlder = loadingOlder,
        contentPadding = PaddingValues(16.dp),
        verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Bottom),
    ) { message ->
        MessageScrollerRow(message)
    }
}

@Composable
fun MessageScrollerLoadOlder() {
    var messages by remember { mutableStateOf(messageScrollerLongThread(15, 45)) }
    var loading by remember { mutableStateOf(false) }
    val scope = rememberCoroutineScope()
    val state = rememberMessageScrollerState()
    val oldest = messages.firstOrNull()?.id?.drop(1)?.toIntOrNull() ?: 0
    val loadOlder = {
        if (!loading && oldest > 0) {
            loading = true
            scope.launch {
                delay(800)
                messages = messageScrollerLongThread(minOf(15, oldest), maxOf(0, oldest - 15)) + messages
                loading = false
            }
        }
    }
    MessageScrollerFrame(ScreenEdge.entries.toSet()) {
        MessageScrollerList(
            messages,
            state,
            Modifier.weight(1f).fillMaxWidth(),
            onLoadOlder = { loadOlder() },
            loadingOlder = loading,
        )
    }
}

API Reference

01

MessageScrollerProvider

Shares one scroll controller with components outside the scroller.

PropTypeDefaultDescription
childrenReact.ReactNode-
scrollEdgeThresholdnumber8Distance in points from an edge that still counts as being at that edge.

Also accepts the props of MessageScrollerOptions.

02

MessageScroller

Container that clips the list and positions the button.

Also accepts the props of ViewProps.

03

MessageScrollerViewport

Inverted FlatList that renders the messages.

PropTypeDefaultDescription
datareadonly T[]RequiredThe messages to render.
keyExtractor(item: T, index: number) => stringRequiredReturns a stable key for each message.
renderItem(info: { item: T; index: number }) => React.ReactElement | nullRequiredRenders one message.
onLoadOlder() => void-Called near the top so older messages can be loaded.

Also accepts the props of MessageScrollerViewportProps<T>.

04

MessageScrollerItem

Row wrapper with horizontal padding.

Also accepts the props of ViewProps.

05

MessageScrollerButton

Floating button that scrolls to the newest or oldest message.

PropTypeDefaultDescription
childrenReact.ReactNode-
directionMessageScrollerScrollDirection"end"end scrolls to the newest message, start to the oldest. Defaults to end.
animatedbooleantrueWhether the scroll is animated. Defaults to true.

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

Accessibility

  • The button has a default label, "Scroll to end" or "Scroll to start", and includes the number of new messages when there are any.
  • While the button is hidden it ignores touches and is hidden from VoiceOver and TalkBack.
  • The button animation respects the system reduce motion setting.

Notes

  • The keyboard dismisses interactively on iOS and on drag on Android, and taps on handled children keep it open.
  • The button uses bg-card with border-border, and the badge uses bg-primary with text-primary-foreground.
  • Light and dark colors follow the system appearance.
iOS
Message Scroller on iOS in light modeMessage Scroller on iOS in dark mode
import { ArrowUpIcon, ChecksIcon, XIcon } from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import { Attachment, AttachmentAction, AttachmentActions, AttachmentContent, AttachmentDescription, AttachmentLabel, AttachmentMedia, AttachmentProgress, AttachmentTitle } from "@/components/ui/attachment"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { BottomBar, BottomBarContent } from "@/components/ui/bottom-bar"
import { Bubble, BubbleContent, BubbleReactions } from "@/components/ui/bubble"
import { Icon } from "@/components/ui/icon"
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group"
import { Message, MessageAvatar, MessageContent, MessageFooter, MessageHeader } from "@/components/ui/message"
import { MessageScroller, MessageScrollerButton, MessageScrollerItem, MessageScrollerProvider, MessageScrollerViewport, useMessageScroller } from "@/components/ui/message-scroller"
import { Text } from "@/components/ui/text"

type ChatMessage = {
  id: string
  from: "me" | "them"
  texts: string[]
  time?: string
  status?: string
  reaction?: string
  attachment?: { name: string; size: string; progress?: number }
}

const thread: ChatMessage[] = [
  {
    id: "1",
    from: "them",
    texts: ["Hey, do you have a minute?", "The client moved the launch."],
    time: "9:12 AM",
  },
  { id: "2", from: "me", texts: ["Sure, what's up?"] },
  {
    id: "3",
    from: "them",
    texts: ["It's Thursday now. Two days earlier than planned."],
    reaction: "😬 1",
  },
  {
    id: "4",
    from: "me",
    texts: [
      "We could ship onboarding behind a flag.",
      "Turn it on next week once QA is done.",
    ],
    status: "Read 9:15 AM",
  },
  {
    id: "5",
    from: "them",
    texts: ["Here are the release notes."],
    attachment: { name: "Release notes.pdf", size: "1.2 MB" },
    time: "9:18 AM",
  },
  {
    id: "6",
    from: "me",
    texts: ["And the checklist with my comments."],
    attachment: { name: "Checklist.pdf", size: "Uploading", progress: 64 },
  },
  {
    id: "7",
    from: "them",
    texts: ["Perfect. Let's sync tomorrow at ten."],
    reaction: "👍 2",
  },
  {
    id: "8",
    from: "me",
    texts: ["Sounds good, see you then."],
    status: "Delivered",
  },
]

function ChatRow({ message }: { message: ChatMessage }) {
  const mine = message.from === "me"
  return (
    <MessageScrollerItem>
      <Message align={mine ? "end" : "start"}>
        {mine ? null : (
          <MessageAvatar>
            <Avatar>
              <AvatarFallback>MK</AvatarFallback>
            </Avatar>
          </MessageAvatar>
        )}
        <MessageContent>
          {message.time && !mine ? (
            <MessageHeader>Maya · {message.time}</MessageHeader>
          ) : null}
          {message.attachment ? (
            <Attachment
              state={message.attachment.progress ? "uploading" : "done"}
              className="w-64"
            >
              <AttachmentMedia variant="document">
                <AttachmentLabel className="bg-red-500">pdf</AttachmentLabel>
              </AttachmentMedia>
              <AttachmentContent>
                <AttachmentTitle>{message.attachment.name}</AttachmentTitle>
                {message.attachment.progress ? (
                  <AttachmentProgress
                    value={message.attachment.progress}
                    accessibilityLabel="Uploading"
                  />
                ) : (
                  <AttachmentDescription>
                    {message.attachment.size}
                  </AttachmentDescription>
                )}
              </AttachmentContent>
              {message.attachment.progress ? (
                <AttachmentActions>
                  <AttachmentAction accessibilityLabel="Cancel upload">
                    <Icon as={XIcon} />
                  </AttachmentAction>
                </AttachmentActions>
              ) : null}
            </Attachment>
          ) : null}
          {message.texts.map((text, index) => (
            <Bubble key={text} variant={mine ? "default" : "muted"}>
              <BubbleContent>{text}</BubbleContent>
              {message.reaction && index === message.texts.length - 1 ? (
                <BubbleReactions align={mine ? "start" : "end"}>
                  {message.reaction}
                </BubbleReactions>
              ) : null}
            </Bubble>
          ))}
          {message.status ? (
            <MessageFooter className="gap-1">
              <Icon as={ChecksIcon} className="text-primary" />
              <Text>{message.status}</Text>
            </MessageFooter>
          ) : null}
        </MessageContent>
      </Message>
    </MessageScrollerItem>
  )
}

function Composer({ onSend }: { onSend: (text: string) => void }) {
  const [value, setValue] = React.useState("")
  const { scrollToEnd } = useMessageScroller()
  const send = () => {
    const text = value.trim()
    if (!text) return
    onSend(text)
    setValue("")
    requestAnimationFrame(() => scrollToEnd())
  }
  return (
    <BottomBar separator>
      <BottomBarContent>
        <InputGroup>
          <InputGroupInput
            placeholder="Message"
            value={value}
            onValueChange={setValue}
            returnKeyType="send"
            onSubmitEditing={send}
          />
          <InputGroupAddon align="inline-end">
            <InputGroupButton
              variant="default"
              size="icon-xs"
              accessibilityLabel="Send"
              disabled={!value.trim()}
              onPress={send}
            >
              <Icon as={ArrowUpIcon} />
            </InputGroupButton>
          </InputGroupAddon>
        </InputGroup>
      </BottomBarContent>
    </BottomBar>
  )
}

type ChatReady = (
  append: (messages: ChatMessage[]) => void,
  scroller: ReturnType<typeof useMessageScroller>
) => void

function ReadyEffect({
  append,
  onReady,
}: {
  append: (messages: ChatMessage[]) => void
  onReady?: ChatReady
}) {
  const scroller = useMessageScroller()
  const readyRef = React.useRef(onReady)
  const scrollerRef = React.useRef(scroller)

  React.useEffect(() => {
    readyRef.current?.(append, scrollerRef.current)
  }, [append])

  return null
}

function Chat({
  initial,
  empty,
  onReady,
}: {
  initial: ChatMessage[]
  empty?: React.ReactElement
  onReady?: ChatReady
}) {
  const [messages, setMessages] = React.useState(initial)
  const append = React.useCallback((next: ChatMessage[]) => {
    setMessages((current) => [...current, ...next])
  }, [])

  return (
    <MessageScrollerProvider>
      <ReadyEffect append={append} onReady={onReady} />
      <View className="-m-4 flex-1">
        <MessageScroller>
          <MessageScrollerViewport
            data={messages}
            keyExtractor={(message) => message.id}
            renderItem={({ item }) => <ChatRow message={item} />}
            contentContainerClassName="gap-4 py-4"
            ListEmptyComponent={empty}
          />
          <MessageScrollerButton />
        </MessageScroller>
        <Composer
          onSend={(text) =>
            append([{ id: `sent-${Date.now()}`, from: "me", texts: [text] }])
          }
        />
      </View>
    </MessageScrollerProvider>
  )
}

export function MessageScrollerExample() {
  return (
    <Chat initial={thread} />
  )
}
import EsuissUI
import PhosphorSwift
import SwiftUI

struct MessageScrollerExample: View {
  var body: some View {
    MessageScrollerChat(initial: MessageScrollerData.thread)
  }
}

struct MessageScrollerAttachment: Hashable {
  let name: String
  let size: String
  var progress: Double?
}

struct MessageScrollerMessage: Identifiable, Hashable {
  let id: String
  let fromMe: Bool
  let texts: [String]
  var time: String?
  var status: String?
  var reaction: String?
  var attachment: MessageScrollerAttachment?
}

enum MessageScrollerData {
  static let thread: [MessageScrollerMessage] = [
    MessageScrollerMessage(
      id: "1",
      fromMe: false,
      texts: ["Hey, do you have a minute?", "The client moved the launch."],
      time: "9:12 AM"
    ),
    MessageScrollerMessage(id: "2", fromMe: true, texts: ["Sure, what's up?"]),
    MessageScrollerMessage(
      id: "3",
      fromMe: false,
      texts: ["It's Thursday now. Two days earlier than planned."],
      reaction: "😬 1"
    ),
    MessageScrollerMessage(
      id: "4",
      fromMe: true,
      texts: ["We could ship onboarding behind a flag.", "Turn it on next week once QA is done."],
      status: "Read 9:15 AM"
    ),
    MessageScrollerMessage(
      id: "5",
      fromMe: false,
      texts: ["Here are the release notes."],
      time: "9:18 AM",
      attachment: MessageScrollerAttachment(name: "Release notes.pdf", size: "1.2 MB")
    ),
    MessageScrollerMessage(
      id: "6",
      fromMe: true,
      texts: ["And the checklist with my comments."],
      attachment: MessageScrollerAttachment(name: "Checklist.pdf", size: "Uploading", progress: 64)
    ),
    MessageScrollerMessage(
      id: "7",
      fromMe: false,
      texts: ["Perfect. Let's sync tomorrow at ten."],
      reaction: "👍 2"
    ),
    MessageScrollerMessage(id: "8", fromMe: true, texts: ["Sounds good, see you then."], status: "Delivered"),
  ]

  static let subjects = [
    "Did you see the latest build?",
    "The onboarding copy needs one more pass.",
    "I can take the release notes.",
    "QA found two small issues on Android.",
    "Both are fixed in the next build.",
    "Can we move the review to the afternoon?",
  ]

  static func long(_ count: Int, offset: Int = 0) -> [MessageScrollerMessage] {
    (0..<count).map { index in
      let number = index + offset
      return MessageScrollerMessage(
        id: "m\(number)",
        fromMe: number % 3 == 0,
        texts: [subjects[number % subjects.count]]
      )
    }
  }
}

struct MessageScrollerRow: View {
  let message: MessageScrollerMessage

  var body: some View {
    let mine = message.fromMe
    Message(align: mine ? .end : .start) {
      if !mine {
        MessageAvatar {
          Avatar { AvatarFallback("MK") }
        }
      }
      MessageContent {
        if let time = message.time, !mine {
          MessageHeader(verbatim: "Maya · \(time)")
        }
        if let attachment = message.attachment {
          MessageScrollerAttachmentView(attachment: attachment)
        }
        ForEach(Array(message.texts.enumerated()), id: \.offset) { index, text in
          Bubble(variant: mine ? .default : .muted) {
            BubbleContent(verbatim: text)
            if let reaction = message.reaction, index == message.texts.count - 1 {
              BubbleReactions(verbatim: reaction, align: mine ? .start : .end)
            }
          }
        }
        if let status = message.status {
          MessageFooter {
            Ph.checks.regular
              .frame(width: 16, height: 16)
              .foregroundStyle(EsuissColor.primary)
            Text(verbatim: status)
          }
        }
      }
    }
  }
}

struct MessageScrollerAttachmentView: View {
  let attachment: MessageScrollerAttachment

  var body: some View {
    Attachment(state: attachment.progress == nil ? .done : .uploading) {
      AttachmentMedia(.document) {
        AttachmentLabel("pdf", tint: Palette.red500)
      }
      AttachmentContent {
        AttachmentTitle(verbatim: attachment.name)
        if let progress = attachment.progress {
          AttachmentProgress(value: progress, label: "Uploading")
        } else {
          AttachmentDescription(verbatim: attachment.size)
        }
      }
      if attachment.progress != nil {
        AttachmentActions {
          AttachmentAction("Cancel upload", icon: Ph.x.bold) {}
        }
      }
    }
    .frame(width: 256)
  }
}

struct MessageScrollerChat: View {
  let unreadDemo: Bool

  @State private var messages: [MessageScrollerMessage]
  @State private var controller = MessageScrollerController()
  @State private var draft = ""

  init(initial: [MessageScrollerMessage], unreadDemo: Bool = false) {
    self.unreadDemo = unreadDemo
    _messages = State(initialValue: initial)
  }

  var body: some View {
    let ready = !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
    VStack(spacing: 0) {
      MessageScroller(messages, spacing: 16, controller: controller) { message in
        MessageScrollerRow(message: message)
      }
      .overlay {
        if messages.isEmpty {
          Empty {
            EmptyHeader {
              EmptyMedia(.icon) { Ph.chatsCircle.regular }
              EmptyTitle("No messages yet")
              EmptyDescription("Say hello to start the conversation.")
            }
          }
        }
      }
      .frame(maxHeight: .infinity)
      BottomBar(separator: true) {
        BottomBarContent {
          InputGroup {
            InputGroupInput("Message", text: $draft)
              .submitLabel(.send)
              .onSubmit(send)
            InputGroupAddon(align: .inlineEnd) {
              InputGroupButton(variant: .default, size: .iconXs, action: send) {
                Ph.arrowUp.bold.frame(width: 14, height: 14)
              }
              .disabled(!ready)
              .accessibilityLabel(Text("Send"))
            }
          }
        }
      }
    }
    .task {
      guard unreadDemo else { return }
      try? await Task.sleep(for: .milliseconds(300))
      controller.scrollToStart(animated: false)
      try? await Task.sleep(for: .milliseconds(600))
      messages.append(contentsOf: [
        MessageScrollerMessage(id: "n1", fromMe: false, texts: ["Are you there?"]),
        MessageScrollerMessage(id: "n2", fromMe: false, texts: ["The build is green."]),
        MessageScrollerMessage(id: "n3", fromMe: false, texts: ["Merging now."]),
      ])
    }
  }

  func send() {
    let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
    guard !text.isEmpty else { return }
    messages.append(MessageScrollerMessage(id: "sent-\(UUID().uuidString)", fromMe: true, texts: [text]))
    draft = ""
    Task { @MainActor in
      controller.scrollToEnd()
    }
  }
}

let thread: [MessageScrollerMessage] = [
  MessageScrollerMessage(
    id: "1",
    fromMe: false,
    texts: ["Hey, do you have a minute?", "The client moved the launch."],
    time: "9:12 AM"
  ),
  MessageScrollerMessage(id: "2", fromMe: true, texts: ["Sure, what's up?"]),
  MessageScrollerMessage(
    id: "3",
    fromMe: false,
    texts: ["It's Thursday now. Two days earlier than planned."],
    reaction: "😬 1"
  ),
  MessageScrollerMessage(
    id: "4",
    fromMe: true,
    texts: ["We could ship onboarding behind a flag.", "Turn it on next week once QA is done."],
    status: "Read 9:15 AM"
  ),
  MessageScrollerMessage(
    id: "5",
    fromMe: false,
    texts: ["Here are the release notes."],
    time: "9:18 AM",
    attachment: MessageScrollerAttachment(name: "Release notes.pdf", size: "1.2 MB")
  ),
  MessageScrollerMessage(
    id: "6",
    fromMe: true,
    texts: ["And the checklist with my comments."],
    attachment: MessageScrollerAttachment(name: "Checklist.pdf", size: "Uploading", progress: 64)
  ),
  MessageScrollerMessage(
    id: "7",
    fromMe: false,
    texts: ["Perfect. Let's sync tomorrow at ten."],
    reaction: "👍 2"
  ),
  MessageScrollerMessage(id: "8", fromMe: true, texts: ["Sounds good, see you then."], status: "Delivered"),
]

let subjects = [
  "Did you see the latest build?",
  "The onboarding copy needs one more pass.",
  "I can take the release notes.",
  "QA found two small issues on Android.",
  "Both are fixed in the next build.",
  "Can we move the review to the afternoon?",
]

func long(_ count: Int, offset: Int = 0) -> [MessageScrollerMessage] {
  (0..<count).map { index in
    let number = index + offset
    return MessageScrollerMessage(
      id: "m\(number)",
      fromMe: number % 3 == 0,
      texts: [subjects[number % subjects.count]]
    )
  }
}
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
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.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.ArrowUp
import com.adamglin.phosphoricons.regular.ChatsCircle
import com.adamglin.phosphoricons.regular.Checks
import com.adamglin.phosphoricons.regular.X
import com.esuiss.ui.components.Attachment
import com.esuiss.ui.components.AttachmentAction
import com.esuiss.ui.components.AttachmentActions
import com.esuiss.ui.components.AttachmentContent
import com.esuiss.ui.components.AttachmentDescription
import com.esuiss.ui.components.AttachmentLabel
import com.esuiss.ui.components.AttachmentMedia
import com.esuiss.ui.components.AttachmentMediaVariant
import com.esuiss.ui.components.AttachmentProgress
import com.esuiss.ui.components.AttachmentState
import com.esuiss.ui.components.AttachmentTitle
import com.esuiss.ui.components.Avatar
import com.esuiss.ui.components.AvatarFallback
import com.esuiss.ui.components.BottomBar
import com.esuiss.ui.components.BottomBarContent
import com.esuiss.ui.components.Bubble
import com.esuiss.ui.components.BubbleAlign
import com.esuiss.ui.components.BubbleContent
import com.esuiss.ui.components.BubbleReactions
import com.esuiss.ui.components.BubbleVariant
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Empty
import com.esuiss.ui.components.EmptyDescription
import com.esuiss.ui.components.EmptyHeader
import com.esuiss.ui.components.EmptyMedia
import com.esuiss.ui.components.EmptyMediaVariant
import com.esuiss.ui.components.EmptyTitle
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.InputGroup
import com.esuiss.ui.components.InputGroupAddon
import com.esuiss.ui.components.InputGroupAddonAlign
import com.esuiss.ui.components.InputGroupButton
import com.esuiss.ui.components.InputGroupButtonSize
import com.esuiss.ui.components.InputGroupInput
import com.esuiss.ui.components.Message
import com.esuiss.ui.components.MessageAlign
import com.esuiss.ui.components.MessageAvatar
import com.esuiss.ui.components.MessageContent
import com.esuiss.ui.components.MessageFooter
import com.esuiss.ui.components.MessageHeader
import com.esuiss.ui.components.MessageScroller
import com.esuiss.ui.components.MessageScrollerState
import com.esuiss.ui.components.Screen
import com.esuiss.ui.components.ScreenEdge
import com.esuiss.ui.components.Text
import com.esuiss.ui.components.rememberMessageScrollerState
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import kotlinx.coroutines.launch

@Composable
fun MessageScrollerExample() {
    MessageScrollerChat(messageScrollerThread)
}

class MessageScrollerFile(val name: String, val size: String, val progress: Float? = null)

class MessageScrollerMessage(
    val id: String,
    val mine: Boolean,
    val texts: List<String>,
    val time: String? = null,
    val status: String? = null,
    val reaction: String? = null,
    val attachment: MessageScrollerFile? = null,
)

val messageScrollerThread = listOf(
    MessageScrollerMessage("1", false, listOf("Hey, do you have a minute?", "The client moved the launch."), time = "9:12 AM"),
    MessageScrollerMessage("2", true, listOf("Sure, what's up?")),
    MessageScrollerMessage("3", false, listOf("It's Thursday now. Two days earlier than planned."), reaction = "😬 1"),
    MessageScrollerMessage(
        "4",
        true,
        listOf("We could ship onboarding behind a flag.", "Turn it on next week once QA is done."),
        status = "Read 9:15 AM",
    ),
    MessageScrollerMessage(
        "5",
        false,
        listOf("Here are the release notes."),
        time = "9:18 AM",
        attachment = MessageScrollerFile("Release notes.pdf", "1.2 MB"),
    ),
    MessageScrollerMessage(
        "6",
        true,
        listOf("And the checklist with my comments."),
        attachment = MessageScrollerFile("Checklist.pdf", "Uploading", 64f),
    ),
    MessageScrollerMessage("7", false, listOf("Perfect. Let's sync tomorrow at ten."), reaction = "👍 2"),
    MessageScrollerMessage("8", true, listOf("Sounds good, see you then."), status = "Delivered"),
)

@Composable
fun MessageScrollerRow(message: MessageScrollerMessage) {
    val colors = EsuissTheme.colors
    val mine = message.mine
    Message(Modifier.fillMaxWidth(), align = if (mine) MessageAlign.End else MessageAlign.Start) {
        if (!mine) {
            MessageAvatar {
                Avatar(fallback = { AvatarFallback("MK") })
            }
        }
        MessageContent {
            if (message.time != null && !mine) MessageHeader("Maya · ${message.time}")
            val file = message.attachment
            if (file != null) {
                Attachment(
                    Modifier.width(256.dp),
                    state = if (file.progress != null) AttachmentState.Uploading else AttachmentState.Done,
                ) {
                    AttachmentMedia(variant = AttachmentMediaVariant.Document) {
                        AttachmentLabel("pdf", background = Palette.red500)
                    }
                    AttachmentContent {
                        AttachmentTitle(file.name)
                        if (file.progress != null) {
                            AttachmentProgress(file.progress, contentDescription = "Uploading")
                        } else {
                            AttachmentDescription(file.size)
                        }
                    }
                    if (file.progress != null) {
                        AttachmentActions {
                            AttachmentAction(onClick = {}, contentDescription = "Cancel upload") {
                                Icon(PhosphorIcons.Regular.X, contentDescription = null)
                            }
                        }
                    }
                }
            }
            message.texts.forEachIndexed { index, text ->
                Bubble(variant = if (mine) BubbleVariant.Default else BubbleVariant.Muted) {
                    BubbleContent(text)
                    if (message.reaction != null && index == message.texts.lastIndex) {
                        BubbleReactions(message.reaction, align = if (mine) BubbleAlign.Start else BubbleAlign.End)
                    }
                }
            }
            if (message.status != null) {
                MessageFooter {
                    Icon(PhosphorIcons.Regular.Checks, contentDescription = null, tint = colors.primary)
                    Text(message.status)
                }
            }
        }
    }
}

@Composable
fun MessageScrollerComposer(state: MessageScrollerState, onSend: (String) -> Unit) {
    var value by rememberSaveable { mutableStateOf("") }
    val scope = rememberCoroutineScope()
    val send = {
        val text = value.trim()
        if (text.isNotEmpty()) {
            onSend(text)
            value = ""
            scope.launch {
                withFrameNanos { }
                state.scrollToEnd()
            }
        }
    }
    BottomBar(separator = true) {
        BottomBarContent {
            InputGroup {
                InputGroupInput(
                    value,
                    { value = it },
                    placeholder = "Message",
                    keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
                    keyboardActions = KeyboardActions(onSend = { send() }),
                )
                InputGroupAddon(align = InputGroupAddonAlign.InlineEnd) {
                    InputGroupButton(
                        onClick = { send() },
                        modifier = Modifier.semantics { contentDescription = "Send" },
                        size = InputGroupButtonSize.IconXs,
                        variant = ButtonVariant.Default,
                        enabled = value.isNotBlank(),
                    ) {
                        Icon(PhosphorIcons.Regular.ArrowUp, contentDescription = null)
                    }
                }
            }
        }
    }
}

@Composable
fun MessageScrollerEmpty(modifier: Modifier) {
    Empty(modifier) {
        EmptyHeader {
            EmptyMedia(variant = EmptyMediaVariant.Icon) {
                Icon(PhosphorIcons.Regular.ChatsCircle, contentDescription = null)
            }
            EmptyTitle("No messages yet")
            EmptyDescription("Say hello to start the conversation.")
        }
    }
}

@Composable
fun MessageScrollerFrame(
    edges: Set<ScreenEdge> = setOf(ScreenEdge.Top, ScreenEdge.Left, ScreenEdge.Right),
    content: @Composable ColumnScope.() -> Unit,
) {
    Screen(edges = edges) {
        Column(Modifier.fillMaxSize()) {
            content()
        }
    }
}

@Composable
fun MessageScrollerList(
    messages: List<MessageScrollerMessage>,
    state: MessageScrollerState,
    modifier: Modifier,
    onLoadOlder: (() -> Unit)? = null,
    loadingOlder: Boolean = false,
) {
    MessageScroller(
        items = messages,
        key = { it.id },
        modifier = modifier,
        state = state,
        onLoadOlder = onLoadOlder,
        loadingOlder = loadingOlder,
        contentPadding = PaddingValues(16.dp),
        verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Bottom),
    ) { message ->
        MessageScrollerRow(message)
    }
}

@Composable
fun MessageScrollerChat(
    initial: List<MessageScrollerMessage>,
    showEmpty: Boolean = false,
    onReady: (suspend (append: (List<MessageScrollerMessage>) -> Unit, state: MessageScrollerState) -> Unit)? = null,
) {
    var messages by remember { mutableStateOf(initial) }
    val state = rememberMessageScrollerState()
    val append: (List<MessageScrollerMessage>) -> Unit = { next -> messages = messages + next }
    if (onReady != null) {
        LaunchedEffect(Unit) { onReady(append, state) }
    }
    MessageScrollerFrame {
        Box(Modifier.weight(1f).fillMaxWidth()) {
            MessageScrollerList(messages, state, Modifier.fillMaxSize())
            if (showEmpty && messages.isEmpty()) {
                MessageScrollerEmpty(Modifier.align(Alignment.Center))
            }
        }
        MessageScrollerComposer(state) { text ->
            append(listOf(MessageScrollerMessage("sent-${System.currentTimeMillis()}", true, listOf(text))))
        }
    }
}
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) {
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) {
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Message, MessageContent } from "@/components/ui/message"
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from "@/components/ui/message-scroller"

const messages = [
  { id: "1", from: "them", text: "Hey, do you have a minute?" },
  { id: "2", from: "me", text: "Sure, what's up?" },
  {
    id: "3",
    from: "them",
    text: "The client moved the launch to Thursday.",
  },
  { id: "4", from: "me", text: "That's two days earlier than planned." },
  { id: "5", from: "them", text: "I know. Can we cut the onboarding tour?" },
  {
    id: "6",
    from: "me",
    text: "We could ship it behind a flag and turn it on next week.",
  },
  { id: "7", from: "them", text: "That works. What about the docs?" },
  {
    id: "8",
    from: "me",
    text: "The API reference is done. Guides need a pass.",
  },
  { id: "9", from: "them", text: "I can review the guides tonight." },
  { id: "10", from: "me", text: "Great, I'll send you the draft links." },
  { id: "11", from: "them", text: "Any blockers on the release checklist?" },
  {
    id: "12",
    from: "me",
    text: "Just the final QA run. It should finish this afternoon.",
  },
  { id: "13", from: "them", text: "Perfect. Let's sync tomorrow at ten." },
  { id: "14", from: "me", text: "Sounds good, see you then." },
]

function Messages() {
  return messages.map((message) => (
    <MessageScrollerItem key={message.id} messageId={message.id}>
      <Message align={message.from === "me" ? "end" : "start"}>
        <MessageContent>
          <Bubble variant={message.from === "me" ? "default" : "muted"}>
            <BubbleContent>{message.text}</BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
    </MessageScrollerItem>
  ))
}

export default function MessageScrollerStartAtTop() {
  return (
    <div className="h-96 w-96 overflow-hidden rounded-2xl border">
      <MessageScrollerProvider defaultScrollPosition="start">
        <MessageScroller>
          <MessageScrollerViewport>
            <MessageScrollerContent className="gap-3 p-4">
              <Messages />
            </MessageScrollerContent>
          </MessageScrollerViewport>
          <MessageScrollerButton direction="start" />
          <MessageScrollerButton />
        </MessageScroller>
      </MessageScrollerProvider>
    </div>
  )
}
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Message, MessageContent } from "@/components/ui/message"
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from "@/components/ui/message-scroller"

const messages = [
  { id: "1", from: "them", text: "Hey, do you have a minute?" },
  { id: "2", from: "me", text: "Sure, what's up?" },
  {
    id: "3",
    from: "them",
    text: "The client moved the launch to Thursday.",
  },
  { id: "4", from: "me", text: "That's two days earlier than planned." },
  { id: "5", from: "them", text: "I know. Can we cut the onboarding tour?" },
  {
    id: "6",
    from: "me",
    text: "We could ship it behind a flag and turn it on next week.",
  },
  { id: "7", from: "them", text: "That works. What about the docs?" },
  {
    id: "8",
    from: "me",
    text: "The API reference is done. Guides need a pass.",
  },
  { id: "9", from: "them", text: "I can review the guides tonight." },
  { id: "10", from: "me", text: "Great, I'll send you the draft links." },
  { id: "11", from: "them", text: "Any blockers on the release checklist?" },
  {
    id: "12",
    from: "me",
    text: "Just the final QA run. It should finish this afternoon.",
  },
  { id: "13", from: "them", text: "Perfect. Let's sync tomorrow at ten." },
  { id: "14", from: "me", text: "Sounds good, see you then." },
]

function Messages() {
  return messages.map((message) => (
    <MessageScrollerItem key={message.id} messageId={message.id}>
      <Message align={message.from === "me" ? "end" : "start"}>
        <MessageContent>
          <Bubble variant={message.from === "me" ? "default" : "muted"}>
            <BubbleContent>{message.text}</BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
    </MessageScrollerItem>
  ))
}

export default function MessageScrollerDemo() {
  return (
    <div className="h-96 w-96 overflow-hidden rounded-2xl border">
      <MessageScrollerProvider>
        <MessageScroller>
          <MessageScrollerViewport>
            <MessageScrollerContent className="gap-3 p-4">
              <Messages />
            </MessageScrollerContent>
          </MessageScrollerViewport>
          <MessageScrollerButton />
        </MessageScroller>
      </MessageScrollerProvider>
    </div>
  )
}
components/ui/message-scroller.tsx
"use client"

import { mergeProps } from "@base-ui/react/merge-props"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { useRender } from "@base-ui/react/use-render"
import { ArrowDownIcon } from "@phosphor-icons/react"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { ScrollBar } from "@/components/ui/scroll-area"
import {
  MessageScrollerContext,
  type MessageScrollerOptions,
  type MessageScrollerScrollDirection,
  useChildListObserver,
  useFrameThrottledResizeObserver,
  useMergedRefs,
swift/Components/MessageScroller.swift
import PhosphorSwift
import SwiftUI

public enum MessageScrollerDefaultScrollPosition: CaseIterable, Sendable {
  case start, end
}

public enum MessageScrollerScrollDirection: CaseIterable, Sendable, Hashable {
  case start, end
}

struct MessageScrollerChange: Equatable {
  var appended = 0
  var prepended = 0
  var reset = false
}
kotlin/components/MessageScroller.kt
package com.esuiss.ui.components

import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
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.BoxScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
kotlin/components/Spinner.kt
package com.esuiss.ui.components

import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.remember
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
uniwind/components/ui/message-scroller.tsx
import { cn } from "cn"
import { ArrowDownIcon, ArrowUpIcon } from "phosphor-react-native"
import * as React from "react"
import {
  FlatList,
  type FlatListProps,
  Platform,
  View,
  type ViewProps,
} from "react-native"
import {
  Easing,
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withTiming,
stylesheet/components/ui/message-scroller.tsx
import { ArrowDownIcon, ArrowUpIcon } from "phosphor-react-native"
import * as React from "react"
import {
  FlatList,
  type FlatListProps,
  Platform,
  StyleSheet,
  View,
  type ViewProps,
} from "react-native"
import {
  Easing,
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withTiming,
nativewind/components/ui/message-scroller.tsx
import { cn } from "cn"
import { ArrowDownIcon, ArrowUpIcon } from "phosphor-react-native"
import * as React from "react"
import {
  FlatList,
  type FlatListProps,
  Platform,
  View,
  type ViewProps,
} from "react-native"
import {
  Easing,
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withTiming,