# Message Scroller

A chat-style message list that stays pinned to the newest message, tracks unread messages and loads older history.

Message Scroller is an open-source mobile component from suiss/uim for React Native and Expo, with SwiftUI and Jetpack Compose versions built from the same design tokens. The @esuiss/uim CLI copies its source into your project, so you own the code.

Source: https://uim.suiss.app/docs/components/message-scroller

![Preview](https://uim.suiss.app/previews/components-message-scroller--default.light.webp)

```tsx
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">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-accent" />
              <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} />
  )
}
```

```swift
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(Color.accent)
            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: .red)
      }
      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]]
    )
  }
}
```

```kotlin
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 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 = colors.red)
                    }
                    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.accent)
                    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))))
        }
    }
}
```

`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

```bash title="Terminal"
npx @esuiss/uim@latest add message-scroller
```

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

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

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

### Manual

**Uniwind**

1. Install the dependencies:

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

2. Add the pieces it builds on: [Button](/docs/components/button), [Icon](/docs/components/icon), [Text](/docs/components/text), and the primitives `components/primitives/hooks.tsx`.

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

```tsx title="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,
  type ViewToken,
} from "react-native"
import {
  Easing,
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withTiming,
} from "react-native-reanimated"
import { useComposedRefs } from "@/components/primitives/hooks"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

type MessageScrollerScrollDirection = "start" | "end"

type MessageScrollerScrollAlign = "start" | "center" | "end"

type MessageScrollerScrollOptions = {
  animated?: boolean
  align?: MessageScrollerScrollAlign
}

type MessageScrollerScrollable = {
  start: boolean
  end: boolean
}

type MessageScrollerVisibilityState = {
  currentAnchorId: string | null
  visibleMessageIds: string[]
}

type MessageScrollerOptions = {
  scrollEdgeThreshold?: number
}

type ScrollList = {
  scrollToOffset: (params: { offset: number; animated?: boolean }) => void
  scrollToEnd: (params?: { animated?: boolean }) => void
  scrollToIndex: (params: {
    index: number
    animated?: boolean
    viewPosition?: number
  }) => void
}

const DEFAULT_SCROLL_EDGE_THRESHOLD = 8

const VIEW_POSITION: Record<MessageScrollerScrollAlign, number> = {
  start: 1,
  center: 0.5,
  end: 0,
}

class SnapshotStore<Snapshot> {
  private snapshot: Snapshot
  private readonly isEqual: (current: Snapshot, next: Snapshot) => boolean
  private readonly listeners = new Set<() => void>()

  constructor(
    initialSnapshot: Snapshot,
    isEqual: (current: Snapshot, next: Snapshot) => boolean
  ) {
    this.snapshot = initialSnapshot
    this.isEqual = isEqual
  }

  getSnapshot = () => this.snapshot

  setSnapshot(nextSnapshot: Snapshot) {
    if (this.isEqual(this.snapshot, nextSnapshot)) return
    this.snapshot = nextSnapshot
    for (const listener of this.listeners) listener()
  }

  subscribe = (listener: () => void) => {
    this.listeners.add(listener)
    return () => {
      this.listeners.delete(listener)
    }
  }
}

class MessageScrollerController {
  readonly scrollableStore = new SnapshotStore<MessageScrollerScrollable>(
    { start: false, end: false },
    (current, next) => current.start === next.start && current.end === next.end
  )
  readonly unreadStore = new SnapshotStore<number>(0, Object.is)
  readonly visibilityStore = new SnapshotStore<MessageScrollerVisibilityState>(
    { currentAnchorId: null, visibleMessageIds: [] },
    (current, next) =>
      current.currentAnchorId === next.currentAnchorId &&
      current.visibleMessageIds.length === next.visibleMessageIds.length &&
      current.visibleMessageIds.every(
        (id, index) => id === next.visibleMessageIds[index]
      )
  )
  threshold: number
  private list: ScrollList | null = null
  private keys: string[] = []
  private offset = 0
  private viewport = 0
  private content = 0
  private pending: {
    index: number
    animated: boolean
    viewPosition: number
  } | null = null

  constructor(threshold: number) {
    this.threshold = threshold
  }

  setList = (list: ScrollList | null) => {
    this.list = list
  }

  setKeys(keys: string[]) {
    const previous = this.keys
    this.keys = keys
    if (previous.length === 0 || keys.length === 0) return
    const newest = previous[previous.length - 1]
    if (keys[keys.length - 1] === newest) return
    const index = keys.lastIndexOf(newest)
    if (index < 0 || this.offset <= this.threshold) return
    this.unreadStore.setSnapshot(
      this.unreadStore.getSnapshot() + keys.length - 1 - index
    )
  }

  setOffset(offset: number) {
    this.offset = offset
    this.sync()
  }

  setViewport(height: number) {
    this.viewport = height
    this.sync()
  }

  setContent(height: number) {
    this.content = height
    this.sync()
  }

  setVisible(ids: string[]) {
    this.visibilityStore.setSnapshot({
      currentAnchorId: ids[0] ?? null,
      visibleMessageIds: ids,
    })
  }

  private sync() {
    const end = this.offset > this.threshold
    const start = this.offset + this.viewport < this.content - this.threshold
    if (!end) this.unreadStore.setSnapshot(0)
    this.scrollableStore.setSnapshot({ start, end })
  }

  scrollToEnd = ({ animated = true }: MessageScrollerScrollOptions = {}) => {
    if (!this.list) return false
    this.list.scrollToOffset({ offset: 0, animated })
    return true
  }

  scrollToStart = ({ animated = true }: MessageScrollerScrollOptions = {}) => {
    if (!this.list) return false
    this.list.scrollToEnd({ animated })
    return true
  }

  scrollToMessage = (
    messageId: string,
    { animated = true, align = "start" }: MessageScrollerScrollOptions = {}
  ) => {
    const position = this.keys.lastIndexOf(messageId)
    if (!this.list || position < 0) return false
    const index = this.keys.length - 1 - position
    const viewPosition = VIEW_POSITION[align]
    this.pending = { index, animated, viewPosition }
    this.list.scrollToIndex({ index, animated, viewPosition })
    return true
  }

  handleScrollToIndexFailed = (info: {
    index: number
    averageItemLength: number
  }) => {
    const pending = this.pending
    this.pending = null
    if (!this.list || !pending || pending.index !== info.index) return
    this.list.scrollToOffset({
      offset: info.averageItemLength * info.index,
      animated: false,
    })
    setTimeout(() => {
      this.list?.scrollToIndex({
        index: pending.index,
        animated: pending.animated,
        viewPosition: pending.viewPosition,
      })
    }, 50)
  }
}

const MessageScrollerContext =
  React.createContext<MessageScrollerController | null>(null)

function useMessageScrollerController(threshold: number) {
  const [controller] = React.useState(
    () => new MessageScrollerController(threshold)
  )
  controller.threshold = threshold
  return controller
}

function useMessageScrollerContext() {
  const controller = React.useContext(MessageScrollerContext)
  if (!controller) {
    throw new Error(
      "useMessageScroller must be used within a <MessageScrollerProvider /> or <MessageScroller />"
    )
  }
  return controller
}

function useMessageScroller() {
  const { scrollToEnd, scrollToMessage, scrollToStart } =
    useMessageScrollerContext()
  return { scrollToEnd, scrollToMessage, scrollToStart }
}

function useMessageScrollerScrollable() {
  const { scrollableStore } = useMessageScrollerContext()
  return React.useSyncExternalStore(
    scrollableStore.subscribe,
    scrollableStore.getSnapshot,
    scrollableStore.getSnapshot
  )
}

function useMessageScrollerUnreadCount() {
  const { unreadStore } = useMessageScrollerContext()
  return React.useSyncExternalStore(
    unreadStore.subscribe,
    unreadStore.getSnapshot,
    unreadStore.getSnapshot
  )
}

function useMessageScrollerVisibility() {
  const { visibilityStore } = useMessageScrollerContext()
  return React.useSyncExternalStore(
    visibilityStore.subscribe,
    visibilityStore.getSnapshot,
    visibilityStore.getSnapshot
  )
}

function MessageScrollerProvider({
  children,
  scrollEdgeThreshold = DEFAULT_SCROLL_EDGE_THRESHOLD,
}: MessageScrollerOptions & { children?: React.ReactNode }) {
  const controller = useMessageScrollerController(scrollEdgeThreshold)
  return (
    <MessageScrollerContext.Provider value={controller}>
      {children}
    </MessageScrollerContext.Provider>
  )
}

function MessageScroller({ className, ...props }: ViewProps) {
  const parent = React.useContext(MessageScrollerContext)
  const own = useMessageScrollerController(DEFAULT_SCROLL_EDGE_THRESHOLD)
  return (
    <MessageScrollerContext.Provider value={parent ?? own}>
      <View
        className={cn("relative min-h-0 flex-1 overflow-hidden", className)}
        {...props}
      />
    </MessageScrollerContext.Provider>
  )
}

type MessageScrollerViewportProps<T> = Omit<
  FlatListProps<T>,
  | "data"
  | "inverted"
  | "keyExtractor"
  | "renderItem"
  | "onEndReached"
  | "getItemLayout"
  | "initialScrollIndex"
  | "onScrollToIndexFailed"
> &
  React.RefAttributes<FlatList<T>> & {
    data: readonly T[]
    keyExtractor: (item: T, index: number) => string
    renderItem: (info: { item: T; index: number }) => React.ReactElement | null
    onLoadOlder?: () => void
  }

function MessageScrollerViewport<T>({
  data,
  keyExtractor,
  renderItem,
  onLoadOlder,
  onEndReachedThreshold = 0.5,
  className,
  contentContainerClassName,
  keyboardDismissMode = Platform.OS === "ios" ? "interactive" : "on-drag",
  keyboardShouldPersistTaps = "handled",
  scrollEventThrottle = 16,
  maintainVisibleContentPosition,
  onScroll,
  onLayout,
  onContentSizeChange,
  onViewableItemsChanged,
  ref,
  ...props
}: MessageScrollerViewportProps<T>) {
  const controller = useMessageScrollerContext()
  const composedRef = useComposedRefs<FlatList<T>>(ref, controller.setList)
  const reversed = React.useMemo(() => [...data].reverse(), [data])
  const keys = React.useMemo(
    () => data.map((item, index) => keyExtractor(item, index)),
    [data, keyExtractor]
  )
  const keysRef = React.useRef(keys)
  keysRef.current = keys
  const viewableRef = React.useRef(onViewableItemsChanged)
  viewableRef.current = onViewableItemsChanged

  React.useLayoutEffect(() => {
    controller.setKeys(keys)
  }, [controller, keys])

  const handleViewableItemsChanged = React.useCallback(
    (info: { viewableItems: ViewToken<T>[]; changed: ViewToken<T>[] }) => {
      const ids = info.viewableItems
        .filter((token) => token.index !== null)
        .sort((a, b) => (b.index ?? 0) - (a.index ?? 0))
        .map((token) => token.key)
      controller.setVisible(ids)
      viewableRef.current?.(info)
    },
    [controller]
  )

  return (
    <FlatList
      ref={composedRef}
      data={reversed}
      inverted
      keyExtractor={(item, index) =>
        keyExtractor(item, reversed.length - 1 - index)
      }
      renderItem={({ item, index }) =>
        renderItem({ item, index: reversed.length - 1 - index })
      }
      onEndReached={onLoadOlder ? () => onLoadOlder() : undefined}
      onEndReachedThreshold={onEndReachedThreshold}
      maintainVisibleContentPosition={
        maintainVisibleContentPosition ?? {
          minIndexForVisible: 0,
          autoscrollToTopThreshold: controller.threshold,
        }
      }
      keyboardDismissMode={keyboardDismissMode}
      keyboardShouldPersistTaps={keyboardShouldPersistTaps}
      scrollEventThrottle={scrollEventThrottle}
      className={cn("flex-1", className)}
      contentContainerClassName={cn("grow gap-8", contentContainerClassName)}
      onScroll={(event) => {
        controller.setOffset(event.nativeEvent.contentOffset.y)
        onScroll?.(event)
      }}
      onLayout={(event) => {
        controller.setViewport(event.nativeEvent.layout.height)
        onLayout?.(event)
      }}
      onContentSizeChange={(width, height) => {
        controller.setContent(height)
        onContentSizeChange?.(width, height)
      }}
      onViewableItemsChanged={handleViewableItemsChanged}
      onScrollToIndexFailed={controller.handleScrollToIndexFailed}
      {...props}
    />
  )
}

function MessageScrollerItem({ className, ...props }: ViewProps) {
  return (
    <View className={cn("min-w-0 shrink-0 px-2.5", className)} {...props} />
  )
}

const ENTER_EASING = Easing.bezier(0.23, 1, 0.32, 1)

const EXIT_EASING = Easing.bezier(0.7, 0, 0.84, 0)

function MessageScrollerButton({
  direction = "end",
  animated = true,
  variant = "secondary",
  size = "icon-sm",
  className,
  children,
  accessibilityLabel,
  onPress,
  ...props
}: Omit<React.ComponentProps<typeof Button>, "children"> & {
  children?: React.ReactNode
  direction?: MessageScrollerScrollDirection
  animated?: boolean
}) {
  const controller = useMessageScrollerContext()
  const scrollable = useMessageScrollerScrollable()
  const unread = useMessageScrollerUnreadCount()
  const active = scrollable[direction]
  const count = direction === "end" ? unread : 0
  const progress = useSharedValue(active ? 1 : 0)
  const sign = direction === "end" ? 1 : -1

  React.useEffect(() => {
    progress.value = withTiming(active ? 1 : 0, {
      duration: active ? 200 : 400,
      easing: active ? ENTER_EASING : EXIT_EASING,
      reduceMotion: ReduceMotion.System,
    })
  }, [active, progress])

  const animatedStyle = useAnimatedStyle(() => ({
    opacity: progress.value,
    transform: [
      { translateY: (1 - progress.value) * 32 * sign },
      { scale: 0.95 + progress.value * 0.05 },
    ],
  }))

  const label =
    direction === "start"
      ? "Scroll to start"
      : count > 0
        ? `Scroll to end, ${count} new ${count === 1 ? "message" : "messages"}`
        : "Scroll to end"

  return (
    <AnimatedView
      pointerEvents={active ? "box-none" : "none"}
      accessibilityElementsHidden={!active}
      importantForAccessibility={active ? "auto" : "no-hide-descendants"}
      className={cn(
        "absolute inset-x-0 items-center",
        direction === "end" ? "bottom-4" : "top-4"
      )}
      style={animatedStyle}
    >
      <Button
        variant={variant}
        size={size}
        accessibilityLabel={accessibilityLabel ?? label}
        className={cn(
          "border-separator bg-surface-raised active:bg-surface-secondary dark:bg-surface-secondary dark:active:bg-surface-tertiary",
          className
        )}
        onPress={(event) => {
          onPress?.(event)
          if (direction === "start") controller.scrollToStart({ animated })
          else controller.scrollToEnd({ animated })
        }}
        {...props}
      >
        {children ?? (
          <Icon as={direction === "start" ? ArrowUpIcon : ArrowDownIcon} />
        )}
        {count > 0 ? (
          <View className="absolute -end-1.5 -top-1.5 h-5 min-w-5 items-center justify-center rounded-full bg-accent px-1.5">
            <Text className="text-2xs font-semibold text-on-accent">
              {count > 99 ? "99+" : count}
            </Text>
          </View>
        ) : null}
      </Button>
    </AnimatedView>
  )
}

export {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
  useMessageScroller,
  useMessageScrollerScrollable,
  useMessageScrollerUnreadCount,
  useMessageScrollerVisibility,
}
```

4. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

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

2. Add the pieces it builds on: [Button](/docs/components/button), [Icon](/docs/components/icon), [Text](/docs/components/text), and the primitives `components/primitives/hooks.tsx`.

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

```tsx title="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,
  type ViewToken,
} from "react-native"
import {
  Easing,
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withTiming,
} from "react-native-reanimated"
import { useComposedRefs } from "@/components/primitives/hooks"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"

type MessageScrollerScrollDirection = "start" | "end"

type MessageScrollerScrollAlign = "start" | "center" | "end"

type MessageScrollerScrollOptions = {
  animated?: boolean
  align?: MessageScrollerScrollAlign
}

type MessageScrollerScrollable = {
  start: boolean
  end: boolean
}

type MessageScrollerVisibilityState = {
  currentAnchorId: string | null
  visibleMessageIds: string[]
}

type MessageScrollerOptions = {
  scrollEdgeThreshold?: number
}

type ScrollList = {
  scrollToOffset: (params: { offset: number; animated?: boolean }) => void
  scrollToEnd: (params?: { animated?: boolean }) => void
  scrollToIndex: (params: {
    index: number
    animated?: boolean
    viewPosition?: number
  }) => void
}

const DEFAULT_SCROLL_EDGE_THRESHOLD = 8

const VIEW_POSITION: Record<MessageScrollerScrollAlign, number> = {
  start: 1,
  center: 0.5,
  end: 0,
}

class SnapshotStore<Snapshot> {
  private snapshot: Snapshot
  private readonly isEqual: (current: Snapshot, next: Snapshot) => boolean
  private readonly listeners = new Set<() => void>()

  constructor(
    initialSnapshot: Snapshot,
    isEqual: (current: Snapshot, next: Snapshot) => boolean
  ) {
    this.snapshot = initialSnapshot
    this.isEqual = isEqual
  }

  getSnapshot = () => this.snapshot

  setSnapshot(nextSnapshot: Snapshot) {
    if (this.isEqual(this.snapshot, nextSnapshot)) return
    this.snapshot = nextSnapshot
    for (const listener of this.listeners) listener()
  }

  subscribe = (listener: () => void) => {
    this.listeners.add(listener)
    return () => {
      this.listeners.delete(listener)
    }
  }
}

class MessageScrollerController {
  readonly scrollableStore = new SnapshotStore<MessageScrollerScrollable>(
    { start: false, end: false },
    (current, next) => current.start === next.start && current.end === next.end
  )
  readonly unreadStore = new SnapshotStore<number>(0, Object.is)
  readonly visibilityStore = new SnapshotStore<MessageScrollerVisibilityState>(
    { currentAnchorId: null, visibleMessageIds: [] },
    (current, next) =>
      current.currentAnchorId === next.currentAnchorId &&
      current.visibleMessageIds.length === next.visibleMessageIds.length &&
      current.visibleMessageIds.every(
        (id, index) => id === next.visibleMessageIds[index]
      )
  )
  threshold: number
  private list: ScrollList | null = null
  private keys: string[] = []
  private offset = 0
  private viewport = 0
  private content = 0
  private pending: {
    index: number
    animated: boolean
    viewPosition: number
  } | null = null

  constructor(threshold: number) {
    this.threshold = threshold
  }

  setList = (list: ScrollList | null) => {
    this.list = list
  }

  setKeys(keys: string[]) {
    const previous = this.keys
    this.keys = keys
    if (previous.length === 0 || keys.length === 0) return
    const newest = previous[previous.length - 1]
    if (keys[keys.length - 1] === newest) return
    const index = keys.lastIndexOf(newest)
    if (index < 0 || this.offset <= this.threshold) return
    this.unreadStore.setSnapshot(
      this.unreadStore.getSnapshot() + keys.length - 1 - index
    )
  }

  setOffset(offset: number) {
    this.offset = offset
    this.sync()
  }

  setViewport(height: number) {
    this.viewport = height
    this.sync()
  }

  setContent(height: number) {
    this.content = height
    this.sync()
  }

  setVisible(ids: string[]) {
    this.visibilityStore.setSnapshot({
      currentAnchorId: ids[0] ?? null,
      visibleMessageIds: ids,
    })
  }

  private sync() {
    const end = this.offset > this.threshold
    const start = this.offset + this.viewport < this.content - this.threshold
    if (!end) this.unreadStore.setSnapshot(0)
    this.scrollableStore.setSnapshot({ start, end })
  }

  scrollToEnd = ({ animated = true }: MessageScrollerScrollOptions = {}) => {
    if (!this.list) return false
    this.list.scrollToOffset({ offset: 0, animated })
    return true
  }

  scrollToStart = ({ animated = true }: MessageScrollerScrollOptions = {}) => {
    if (!this.list) return false
    this.list.scrollToEnd({ animated })
    return true
  }

  scrollToMessage = (
    messageId: string,
    { animated = true, align = "start" }: MessageScrollerScrollOptions = {}
  ) => {
    const position = this.keys.lastIndexOf(messageId)
    if (!this.list || position < 0) return false
    const index = this.keys.length - 1 - position
    const viewPosition = VIEW_POSITION[align]
    this.pending = { index, animated, viewPosition }
    this.list.scrollToIndex({ index, animated, viewPosition })
    return true
  }

  handleScrollToIndexFailed = (info: {
    index: number
    averageItemLength: number
  }) => {
    const pending = this.pending
    this.pending = null
    if (!this.list || !pending || pending.index !== info.index) return
    this.list.scrollToOffset({
      offset: info.averageItemLength * info.index,
      animated: false,
    })
    setTimeout(() => {
      this.list?.scrollToIndex({
        index: pending.index,
        animated: pending.animated,
        viewPosition: pending.viewPosition,
      })
    }, 50)
  }
}

const MessageScrollerContext =
  React.createContext<MessageScrollerController | null>(null)

function useMessageScrollerController(threshold: number) {
  const [controller] = React.useState(
    () => new MessageScrollerController(threshold)
  )
  controller.threshold = threshold
  return controller
}

function useMessageScrollerContext() {
  const controller = React.useContext(MessageScrollerContext)
  if (!controller) {
    throw new Error(
      "useMessageScroller must be used within a <MessageScrollerProvider /> or <MessageScroller />"
    )
  }
  return controller
}

function useMessageScroller() {
  const { scrollToEnd, scrollToMessage, scrollToStart } =
    useMessageScrollerContext()
  return { scrollToEnd, scrollToMessage, scrollToStart }
}

function useMessageScrollerScrollable() {
  const { scrollableStore } = useMessageScrollerContext()
  return React.useSyncExternalStore(
    scrollableStore.subscribe,
    scrollableStore.getSnapshot,
    scrollableStore.getSnapshot
  )
}

function useMessageScrollerUnreadCount() {
  const { unreadStore } = useMessageScrollerContext()
  return React.useSyncExternalStore(
    unreadStore.subscribe,
    unreadStore.getSnapshot,
    unreadStore.getSnapshot
  )
}

function useMessageScrollerVisibility() {
  const { visibilityStore } = useMessageScrollerContext()
  return React.useSyncExternalStore(
    visibilityStore.subscribe,
    visibilityStore.getSnapshot,
    visibilityStore.getSnapshot
  )
}

function MessageScrollerProvider({
  children,
  scrollEdgeThreshold = DEFAULT_SCROLL_EDGE_THRESHOLD,
}: MessageScrollerOptions & { children?: React.ReactNode }) {
  const controller = useMessageScrollerController(scrollEdgeThreshold)
  return (
    <MessageScrollerContext.Provider value={controller}>
      {children}
    </MessageScrollerContext.Provider>
  )
}

function MessageScroller({ className, ...props }: ViewProps) {
  const parent = React.useContext(MessageScrollerContext)
  const own = useMessageScrollerController(DEFAULT_SCROLL_EDGE_THRESHOLD)
  return (
    <MessageScrollerContext.Provider value={parent ?? own}>
      <View
        className={cn("relative min-h-0 flex-1 overflow-hidden", className)}
        {...props}
      />
    </MessageScrollerContext.Provider>
  )
}

type MessageScrollerViewportProps<T> = Omit<
  FlatListProps<T>,
  | "data"
  | "inverted"
  | "keyExtractor"
  | "renderItem"
  | "onEndReached"
  | "getItemLayout"
  | "initialScrollIndex"
  | "onScrollToIndexFailed"
> &
  React.RefAttributes<FlatList<T>> & {
    data: readonly T[]
    keyExtractor: (item: T, index: number) => string
    renderItem: (info: { item: T; index: number }) => React.ReactElement | null
    onLoadOlder?: () => void
  }

function MessageScrollerViewport<T>({
  data,
  keyExtractor,
  renderItem,
  onLoadOlder,
  onEndReachedThreshold = 0.5,
  className,
  contentContainerClassName,
  keyboardDismissMode = Platform.OS === "ios" ? "interactive" : "on-drag",
  keyboardShouldPersistTaps = "handled",
  scrollEventThrottle = 16,
  maintainVisibleContentPosition,
  onScroll,
  onLayout,
  onContentSizeChange,
  onViewableItemsChanged,
  ref,
  ...props
}: MessageScrollerViewportProps<T>) {
  const controller = useMessageScrollerContext()
  const composedRef = useComposedRefs<FlatList<T>>(ref, controller.setList)
  const reversed = React.useMemo(() => [...data].reverse(), [data])
  const keys = React.useMemo(
    () => data.map((item, index) => keyExtractor(item, index)),
    [data, keyExtractor]
  )
  const keysRef = React.useRef(keys)
  keysRef.current = keys
  const viewableRef = React.useRef(onViewableItemsChanged)
  viewableRef.current = onViewableItemsChanged

  React.useLayoutEffect(() => {
    controller.setKeys(keys)
  }, [controller, keys])

  const handleViewableItemsChanged = React.useCallback(
    (info: { viewableItems: ViewToken<T>[]; changed: ViewToken<T>[] }) => {
      const ids = info.viewableItems
        .filter((token) => token.index !== null)
        .sort((a, b) => (b.index ?? 0) - (a.index ?? 0))
        .map((token) => token.key)
      controller.setVisible(ids)
      viewableRef.current?.(info)
    },
    [controller]
  )

  return (
    <FlatList
      ref={composedRef}
      data={reversed}
      inverted
      keyExtractor={(item, index) =>
        keyExtractor(item, reversed.length - 1 - index)
      }
      renderItem={({ item, index }) =>
        renderItem({ item, index: reversed.length - 1 - index })
      }
      onEndReached={onLoadOlder ? () => onLoadOlder() : undefined}
      onEndReachedThreshold={onEndReachedThreshold}
      maintainVisibleContentPosition={
        maintainVisibleContentPosition ?? {
          minIndexForVisible: 0,
          autoscrollToTopThreshold: controller.threshold,
        }
      }
      keyboardDismissMode={keyboardDismissMode}
      keyboardShouldPersistTaps={keyboardShouldPersistTaps}
      scrollEventThrottle={scrollEventThrottle}
      className={cn("flex-1", className)}
      contentContainerClassName={cn("grow gap-8", contentContainerClassName)}
      onScroll={(event) => {
        controller.setOffset(event.nativeEvent.contentOffset.y)
        onScroll?.(event)
      }}
      onLayout={(event) => {
        controller.setViewport(event.nativeEvent.layout.height)
        onLayout?.(event)
      }}
      onContentSizeChange={(width, height) => {
        controller.setContent(height)
        onContentSizeChange?.(width, height)
      }}
      onViewableItemsChanged={handleViewableItemsChanged}
      onScrollToIndexFailed={controller.handleScrollToIndexFailed}
      {...props}
    />
  )
}

function MessageScrollerItem({ className, ...props }: ViewProps) {
  return (
    <View className={cn("min-w-0 shrink-0 px-2.5", className)} {...props} />
  )
}

const ENTER_EASING = Easing.bezier(0.23, 1, 0.32, 1)

const EXIT_EASING = Easing.bezier(0.7, 0, 0.84, 0)

function MessageScrollerButton({
  direction = "end",
  animated = true,
  variant = "secondary",
  size = "icon-sm",
  className,
  children,
  accessibilityLabel,
  onPress,
  ...props
}: Omit<React.ComponentProps<typeof Button>, "children"> & {
  children?: React.ReactNode
  direction?: MessageScrollerScrollDirection
  animated?: boolean
}) {
  const controller = useMessageScrollerContext()
  const scrollable = useMessageScrollerScrollable()
  const unread = useMessageScrollerUnreadCount()
  const active = scrollable[direction]
  const count = direction === "end" ? unread : 0
  const progress = useSharedValue(active ? 1 : 0)
  const sign = direction === "end" ? 1 : -1

  React.useEffect(() => {
    progress.value = withTiming(active ? 1 : 0, {
      duration: active ? 200 : 400,
      easing: active ? ENTER_EASING : EXIT_EASING,
      reduceMotion: ReduceMotion.System,
    })
  }, [active, progress])

  const animatedStyle = useAnimatedStyle(() => ({
    opacity: progress.value,
    transform: [
      { translateY: (1 - progress.value) * 32 * sign },
      { scale: 0.95 + progress.value * 0.05 },
    ],
  }))

  const label =
    direction === "start"
      ? "Scroll to start"
      : count > 0
        ? `Scroll to end, ${count} new ${count === 1 ? "message" : "messages"}`
        : "Scroll to end"

  return (
    <AnimatedView
      pointerEvents={active ? "box-none" : "none"}
      accessibilityElementsHidden={!active}
      importantForAccessibility={active ? "auto" : "no-hide-descendants"}
      className={cn(
        "absolute inset-x-0 items-center",
        direction === "end" ? "bottom-4" : "top-4"
      )}
      style={animatedStyle}
    >
      <Button
        variant={variant}
        size={size}
        accessibilityLabel={accessibilityLabel ?? label}
        className={cn(
          "border-separator bg-surface-raised active:bg-surface-secondary dark:bg-surface-secondary dark:active:bg-surface-tertiary",
          className
        )}
        onPress={(event) => {
          onPress?.(event)
          if (direction === "start") controller.scrollToStart({ animated })
          else controller.scrollToEnd({ animated })
        }}
        {...props}
      >
        {children ?? (
          <Icon as={direction === "start" ? ArrowUpIcon : ArrowDownIcon} />
        )}
        {count > 0 ? (
          <View className="absolute -end-1.5 -top-1.5 h-5 min-w-5 items-center justify-center rounded-full bg-accent px-1.5">
            <Text className="text-2xs font-semibold text-on-accent">
              {count > 99 ? "99+" : count}
            </Text>
          </View>
        ) : null}
      </Button>
    </AnimatedView>
  )
}

export {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
  useMessageScroller,
  useMessageScrollerScrollable,
  useMessageScrollerUnreadCount,
  useMessageScrollerVisibility,
}
```

4. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

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

2. Add the pieces it builds on: [Button](/docs/components/button), [Icon](/docs/components/icon), [Text](/docs/components/text), and the primitives `components/primitives/hooks.tsx`.

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

```tsx title="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,
  type ViewToken,
} from "react-native"
import {
  Easing,
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withTiming,
} from "react-native-reanimated"
import { useComposedRefs } from "@/components/primitives/hooks"
import { AnimatedView } from "@/components/ui/animated-view"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import { Text } from "@/components/ui/text"
import { fontSize, radius, useTheme } from "@/components/ui/theme"

type MessageScrollerScrollDirection = "start" | "end"

type MessageScrollerScrollAlign = "start" | "center" | "end"

type MessageScrollerScrollOptions = {
  animated?: boolean
  align?: MessageScrollerScrollAlign
}

type MessageScrollerScrollable = {
  start: boolean
  end: boolean
}

type MessageScrollerVisibilityState = {
  currentAnchorId: string | null
  visibleMessageIds: string[]
}

type MessageScrollerOptions = {
  scrollEdgeThreshold?: number
}

type ScrollList = {
  scrollToOffset: (params: { offset: number; animated?: boolean }) => void
  scrollToEnd: (params?: { animated?: boolean }) => void
  scrollToIndex: (params: {
    index: number
    animated?: boolean
    viewPosition?: number
  }) => void
}

const DEFAULT_SCROLL_EDGE_THRESHOLD = 8

const styles = StyleSheet.create({
  root: { position: "relative", minHeight: 0, flex: 1, overflow: "hidden" },
  viewport: { flex: 1 },
  contentContainer: { flexGrow: 1, gap: 32 },
  item: { minWidth: 0, flexShrink: 0, paddingHorizontal: 10 },
  buttonFrame: {
    position: "absolute",
    left: 0,
    right: 0,
    alignItems: "center",
  },
  badge: {
    position: "absolute",
    top: -6,
    end: -6,
    height: 20,
    minWidth: 20,
    alignItems: "center",
    justifyContent: "center",
    borderRadius: radius.full,
    paddingHorizontal: 6,
  },
  badgeText: { ...fontSize["2xs"], fontWeight: "600" },
})

const VIEW_POSITION: Record<MessageScrollerScrollAlign, number> = {
  start: 1,
  center: 0.5,
  end: 0,
}

class SnapshotStore<Snapshot> {
  private snapshot: Snapshot
  private readonly isEqual: (current: Snapshot, next: Snapshot) => boolean
  private readonly listeners = new Set<() => void>()

  constructor(
    initialSnapshot: Snapshot,
    isEqual: (current: Snapshot, next: Snapshot) => boolean
  ) {
    this.snapshot = initialSnapshot
    this.isEqual = isEqual
  }

  getSnapshot = () => this.snapshot

  setSnapshot(nextSnapshot: Snapshot) {
    if (this.isEqual(this.snapshot, nextSnapshot)) return
    this.snapshot = nextSnapshot
    for (const listener of this.listeners) listener()
  }

  subscribe = (listener: () => void) => {
    this.listeners.add(listener)
    return () => {
      this.listeners.delete(listener)
    }
  }
}

class MessageScrollerController {
  readonly scrollableStore = new SnapshotStore<MessageScrollerScrollable>(
    { start: false, end: false },
    (current, next) => current.start === next.start && current.end === next.end
  )
  readonly unreadStore = new SnapshotStore<number>(0, Object.is)
  readonly visibilityStore = new SnapshotStore<MessageScrollerVisibilityState>(
    { currentAnchorId: null, visibleMessageIds: [] },
    (current, next) =>
      current.currentAnchorId === next.currentAnchorId &&
      current.visibleMessageIds.length === next.visibleMessageIds.length &&
      current.visibleMessageIds.every(
        (id, index) => id === next.visibleMessageIds[index]
      )
  )
  threshold: number
  private list: ScrollList | null = null
  private keys: string[] = []
  private offset = 0
  private viewport = 0
  private content = 0
  private pending: {
    index: number
    animated: boolean
    viewPosition: number
  } | null = null

  constructor(threshold: number) {
    this.threshold = threshold
  }

  setList = (list: ScrollList | null) => {
    this.list = list
  }

  setKeys(keys: string[]) {
    const previous = this.keys
    this.keys = keys
    if (previous.length === 0 || keys.length === 0) return
    const newest = previous[previous.length - 1]
    if (keys[keys.length - 1] === newest) return
    const index = keys.lastIndexOf(newest)
    if (index < 0 || this.offset <= this.threshold) return
    this.unreadStore.setSnapshot(
      this.unreadStore.getSnapshot() + keys.length - 1 - index
    )
  }

  setOffset(offset: number) {
    this.offset = offset
    this.sync()
  }

  setViewport(height: number) {
    this.viewport = height
    this.sync()
  }

  setContent(height: number) {
    this.content = height
    this.sync()
  }

  setVisible(ids: string[]) {
    this.visibilityStore.setSnapshot({
      currentAnchorId: ids[0] ?? null,
      visibleMessageIds: ids,
    })
  }

  private sync() {
    const end = this.offset > this.threshold
    const start = this.offset + this.viewport < this.content - this.threshold
    if (!end) this.unreadStore.setSnapshot(0)
    this.scrollableStore.setSnapshot({ start, end })
  }

  scrollToEnd = ({ animated = true }: MessageScrollerScrollOptions = {}) => {
    if (!this.list) return false
    this.list.scrollToOffset({ offset: 0, animated })
    return true
  }

  scrollToStart = ({ animated = true }: MessageScrollerScrollOptions = {}) => {
    if (!this.list) return false
    this.list.scrollToEnd({ animated })
    return true
  }

  scrollToMessage = (
    messageId: string,
    { animated = true, align = "start" }: MessageScrollerScrollOptions = {}
  ) => {
    const position = this.keys.lastIndexOf(messageId)
    if (!this.list || position < 0) return false
    const index = this.keys.length - 1 - position
    const viewPosition = VIEW_POSITION[align]
    this.pending = { index, animated, viewPosition }
    this.list.scrollToIndex({ index, animated, viewPosition })
    return true
  }

  handleScrollToIndexFailed = (info: {
    index: number
    averageItemLength: number
  }) => {
    const pending = this.pending
    this.pending = null
    if (!this.list || !pending || pending.index !== info.index) return
    this.list.scrollToOffset({
      offset: info.averageItemLength * info.index,
      animated: false,
    })
    setTimeout(() => {
      this.list?.scrollToIndex({
        index: pending.index,
        animated: pending.animated,
        viewPosition: pending.viewPosition,
      })
    }, 50)
  }
}

const MessageScrollerContext =
  React.createContext<MessageScrollerController | null>(null)

function useMessageScrollerController(threshold: number) {
  const [controller] = React.useState(
    () => new MessageScrollerController(threshold)
  )
  controller.threshold = threshold
  return controller
}

function useMessageScrollerContext() {
  const controller = React.useContext(MessageScrollerContext)
  if (!controller) {
    throw new Error(
      "useMessageScroller must be used within a <MessageScrollerProvider /> or <MessageScroller />"
    )
  }
  return controller
}

function useMessageScroller() {
  const { scrollToEnd, scrollToMessage, scrollToStart } =
    useMessageScrollerContext()
  return { scrollToEnd, scrollToMessage, scrollToStart }
}

function useMessageScrollerScrollable() {
  const { scrollableStore } = useMessageScrollerContext()
  return React.useSyncExternalStore(
    scrollableStore.subscribe,
    scrollableStore.getSnapshot,
    scrollableStore.getSnapshot
  )
}

function useMessageScrollerUnreadCount() {
  const { unreadStore } = useMessageScrollerContext()
  return React.useSyncExternalStore(
    unreadStore.subscribe,
    unreadStore.getSnapshot,
    unreadStore.getSnapshot
  )
}

function useMessageScrollerVisibility() {
  const { visibilityStore } = useMessageScrollerContext()
  return React.useSyncExternalStore(
    visibilityStore.subscribe,
    visibilityStore.getSnapshot,
    visibilityStore.getSnapshot
  )
}

function MessageScrollerProvider({
  children,
  scrollEdgeThreshold = DEFAULT_SCROLL_EDGE_THRESHOLD,
}: MessageScrollerOptions & { children?: React.ReactNode }) {
  const controller = useMessageScrollerController(scrollEdgeThreshold)
  return (
    <MessageScrollerContext.Provider value={controller}>
      {children}
    </MessageScrollerContext.Provider>
  )
}

function MessageScroller({ style, ...props }: ViewProps) {
  const parent = React.useContext(MessageScrollerContext)
  const own = useMessageScrollerController(DEFAULT_SCROLL_EDGE_THRESHOLD)
  return (
    <MessageScrollerContext.Provider value={parent ?? own}>
      <View style={[styles.root, style]} {...props} />
    </MessageScrollerContext.Provider>
  )
}

type MessageScrollerViewportProps<T> = Omit<
  FlatListProps<T>,
  | "data"
  | "inverted"
  | "keyExtractor"
  | "renderItem"
  | "onEndReached"
  | "getItemLayout"
  | "initialScrollIndex"
  | "onScrollToIndexFailed"
> &
  React.RefAttributes<FlatList<T>> & {
    data: readonly T[]
    keyExtractor: (item: T, index: number) => string
    renderItem: (info: { item: T; index: number }) => React.ReactElement | null
    onLoadOlder?: () => void
  }

function MessageScrollerViewport<T>({
  data,
  keyExtractor,
  renderItem,
  onLoadOlder,
  onEndReachedThreshold = 0.5,
  style,
  contentContainerStyle,
  keyboardDismissMode = Platform.OS === "ios" ? "interactive" : "on-drag",
  keyboardShouldPersistTaps = "handled",
  scrollEventThrottle = 16,
  maintainVisibleContentPosition,
  onScroll,
  onLayout,
  onContentSizeChange,
  onViewableItemsChanged,
  ref,
  ...props
}: MessageScrollerViewportProps<T>) {
  const controller = useMessageScrollerContext()
  const composedRef = useComposedRefs<FlatList<T>>(ref, controller.setList)
  const reversed = React.useMemo(() => [...data].reverse(), [data])
  const keys = React.useMemo(
    () => data.map((item, index) => keyExtractor(item, index)),
    [data, keyExtractor]
  )
  const keysRef = React.useRef(keys)
  keysRef.current = keys
  const viewableRef = React.useRef(onViewableItemsChanged)
  viewableRef.current = onViewableItemsChanged

  React.useLayoutEffect(() => {
    controller.setKeys(keys)
  }, [controller, keys])

  const handleViewableItemsChanged = React.useCallback(
    (info: { viewableItems: ViewToken<T>[]; changed: ViewToken<T>[] }) => {
      const ids = info.viewableItems
        .filter((token) => token.index !== null)
        .sort((a, b) => (b.index ?? 0) - (a.index ?? 0))
        .map((token) => token.key)
      controller.setVisible(ids)
      viewableRef.current?.(info)
    },
    [controller]
  )

  return (
    <FlatList
      ref={composedRef}
      data={reversed}
      inverted
      keyExtractor={(item, index) =>
        keyExtractor(item, reversed.length - 1 - index)
      }
      renderItem={({ item, index }) =>
        renderItem({ item, index: reversed.length - 1 - index })
      }
      onEndReached={onLoadOlder ? () => onLoadOlder() : undefined}
      onEndReachedThreshold={onEndReachedThreshold}
      maintainVisibleContentPosition={
        maintainVisibleContentPosition ?? {
          minIndexForVisible: 0,
          autoscrollToTopThreshold: controller.threshold,
        }
      }
      keyboardDismissMode={keyboardDismissMode}
      keyboardShouldPersistTaps={keyboardShouldPersistTaps}
      scrollEventThrottle={scrollEventThrottle}
      style={[styles.viewport, style]}
      contentContainerStyle={[styles.contentContainer, contentContainerStyle]}
      onScroll={(event) => {
        controller.setOffset(event.nativeEvent.contentOffset.y)
        onScroll?.(event)
      }}
      onLayout={(event) => {
        controller.setViewport(event.nativeEvent.layout.height)
        onLayout?.(event)
      }}
      onContentSizeChange={(width, height) => {
        controller.setContent(height)
        onContentSizeChange?.(width, height)
      }}
      onViewableItemsChanged={handleViewableItemsChanged}
      onScrollToIndexFailed={controller.handleScrollToIndexFailed}
      {...props}
    />
  )
}

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

const ENTER_EASING = Easing.bezier(0.23, 1, 0.32, 1)

const EXIT_EASING = Easing.bezier(0.7, 0, 0.84, 0)

function MessageScrollerButton({
  direction = "end",
  animated = true,
  variant = "secondary",
  size = "icon-sm",
  style,
  children,
  accessibilityLabel,
  onPress,
  ...props
}: Omit<React.ComponentProps<typeof Button>, "children"> & {
  children?: React.ReactNode
  direction?: MessageScrollerScrollDirection
  animated?: boolean
}) {
  const { colors, isDark } = useTheme()
  const controller = useMessageScrollerContext()
  const scrollable = useMessageScrollerScrollable()
  const unread = useMessageScrollerUnreadCount()
  const active = scrollable[direction]
  const count = direction === "end" ? unread : 0
  const progress = useSharedValue(active ? 1 : 0)
  const sign = direction === "end" ? 1 : -1

  React.useEffect(() => {
    progress.value = withTiming(active ? 1 : 0, {
      duration: active ? 200 : 400,
      easing: active ? ENTER_EASING : EXIT_EASING,
      reduceMotion: ReduceMotion.System,
    })
  }, [active, progress])

  const animatedStyle = useAnimatedStyle(() => ({
    opacity: progress.value,
    transform: [
      { translateY: (1 - progress.value) * 32 * sign },
      { scale: 0.95 + progress.value * 0.05 },
    ],
  }))

  const label =
    direction === "start"
      ? "Scroll to start"
      : count > 0
        ? `Scroll to end, ${count} new ${count === 1 ? "message" : "messages"}`
        : "Scroll to end"

  return (
    <AnimatedView
      pointerEvents={active ? "box-none" : "none"}
      accessibilityElementsHidden={!active}
      importantForAccessibility={active ? "auto" : "no-hide-descendants"}
      style={[
        styles.buttonFrame,
        direction === "end" ? { bottom: 16 } : { top: 16 },
        animatedStyle,
      ]}
    >
      <Button
        variant={variant}
        size={size}
        accessibilityLabel={accessibilityLabel ?? label}
        style={(state) => [
          {
            borderColor: colors.separator,
            backgroundColor: state.pressed
              ? isDark
                ? colors.surfaceTertiary
                : colors.surfaceSecondary
              : isDark
                ? colors.surfaceSecondary
                : colors.surfaceRaised,
          },
          typeof style === "function" ? style(state) : style,
        ]}
        onPress={(event) => {
          onPress?.(event)
          if (direction === "start") controller.scrollToStart({ animated })
          else controller.scrollToEnd({ animated })
        }}
        {...props}
      >
        {children ?? (
          <Icon as={direction === "start" ? ArrowUpIcon : ArrowDownIcon} />
        )}
        {count > 0 ? (
          <View style={[styles.badge, { backgroundColor: colors.accent }]}>
            <Text style={[styles.badgeText, { color: colors.onAccent }]}>
              {count > 99 ? "99+" : count}
            </Text>
          </View>
        ) : null}
      </Button>
    </AnimatedView>
  )
}

export {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
  useMessageScroller,
  useMessageScrollerScrollable,
  useMessageScrollerUnreadCount,
  useMessageScrollerVisibility,
}
```

4. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

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

3. Copy the theme files every view uses:

```swift title="swift/Theme/Theme.swift"
import SwiftUI
import UIKit

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

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

  var uiColor: UIColor {
    UIColor(red: red, green: green, blue: blue, alpha: alpha)
  }
}

extension Color {
  static func adaptive(light: RGBA, dark: RGBA) -> Color {
    let lightColor = light.uiColor
    let darkColor = dark.uiColor
    return Color(uiColor: UIColor { traits in
      traits.userInterfaceStyle == .dark ? darkColor : lightColor
    })
  }
}

public struct TextStyle: Sendable, Hashable {
  public let size: CGFloat
  public let lineHeight: CGFloat
  public let letterSpacing: CGFloat

  public init(size: CGFloat, lineHeight: CGFloat, letterSpacing: CGFloat) {
    self.size = size
    self.lineHeight = lineHeight
    self.letterSpacing = letterSpacing
  }

  public func font(weight: Font.Weight = .regular) -> Font {
    .system(size: size, weight: weight)
  }

  public var lineSpacing: CGFloat {
    max(0, lineHeight - UIFont.systemFont(ofSize: size).lineHeight)
  }
}

extension Hashable where Self == Font {
  public static func textStyle(_ style: TextStyle, weight: Font.Weight = .regular) -> Font {
    style.font(weight: weight)
  }
}

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

  func body(content: Content) -> some View {
    content
      .font(style.font(weight: weight))
      .tracking(style.letterSpacing)
      .lineSpacing(style.lineSpacing)
      .padding(.vertical, style.lineSpacing / 2)
  }
}

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

public struct Shadow: Sendable, Hashable {
  public let color: Color
  public let radius: CGFloat
  public let x: CGFloat
  public let y: CGFloat

  public init(color: Color, radius: CGFloat, x: CGFloat = 0, y: CGFloat = 0) {
    self.color = color
    self.radius = radius
    self.x = x
    self.y = y
  }
}

extension View {
  public func shadow(_ shadow: Shadow) -> some View {
    self.shadow(color: shadow.color, radius: shadow.radius, x: shadow.x, y: shadow.y)
  }
}
```

```swift title="swift/Theme/Tokens.swift"
import SwiftUI

extension Color {
  public static let surface = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x000000))
  public static let surfaceSecondary = Color.adaptive(light: .init(0xF5F5F7), dark: .init(0x161617))
  public static let surfaceTertiary = Color.adaptive(light: .init(0xE8E8ED), dark: .init(0x2C2C2E))
  public static let surfaceRaised = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x1D1D1F))
  public static let label = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let labelSecondary = Color.adaptive(light: .init(0x6E6E73), dark: .init(0xA1A1A6))
  public static let labelTertiary = Color.adaptive(light: .init(0x86868B), dark: .init(0x86868B))
  public static let labelQuaternary = Color.adaptive(light: .init(0xD2D2D7), dark: .init(0x424245))
  public static let control = Color.adaptive(light: .init(0x000000, 0.04), dark: .init(0xFFFFFF, 0.1))
  public static let controlPressed = Color.adaptive(light: .init(0x000000, 0.12), dark: .init(0xFFFFFF, 0.22))
  public static let controlSelected = Color.adaptive(light: .init(0x1D1D1F), dark: .init(0xF5F5F7))
  public static let controlDisabled = Color.adaptive(light: .init(0x000000, 0.03), dark: .init(0xFFFFFF, 0.05))
  public static let onControlSelected = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0x000000))
  public static let itemPressed = Color.adaptive(light: .init(0x000000, 0.08), dark: .init(0xFFFFFF, 0.12))
  public static let itemSelected = Color.adaptive(light: .init(0x000000, 0.06), dark: .init(0xFFFFFF, 0.1))
  public static let separator = Color.adaptive(light: .init(0xD2D2D7), dark: .init(0x424245))
  public static let separatorStrong = Color.adaptive(light: .init(0x86868B), dark: .init(0x6E6E73))
  public static let accent = Color.adaptive(light: .init(0x0071E3), dark: .init(0x0071E3))
  public static let accentPressed = Color.adaptive(light: .init(0x005BB8), dark: .init(0x004D9E))
  public static let accentSelected = Color.adaptive(light: .init(0x0071E3), dark: .init(0x0071E3))
  public static let accentDisabled = Color.adaptive(light: .init(0x0071E3, 0.4), dark: .init(0x0071E3, 0.4))
  public static let onAccent = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0xFFFFFF))
  public static let accentSurface = Color.adaptive(light: .init(0x0071E3, 0.09), dark: .init(0x0071E3, 0.18))
  public static let accentSurfacePressed = Color.adaptive(light: .init(0x0071E3, 0.18), dark: .init(0x0071E3, 0.3))
  public static let link = Color.adaptive(light: .init(0x0066CC), dark: .init(0x2997FF))
  public static let focus = Color.adaptive(light: .init(0x0071E3), dark: .init(0x0071E3))
  public static let scrim = Color.adaptive(light: .init(0x000000, 0.48), dark: .init(0x000000, 0.48))
  public static let media = Color.adaptive(light: .init(0x000000), dark: .init(0x000000))
  public static let onMedia = Color.adaptive(light: .init(0xFFFFFF), dark: .init(0xFFFFFF))
  public static let danger = Color.adaptive(light: .init(0xD0000A), dark: .init(0xFF5447))
  public static let dangerSurface = Color.adaptive(light: .init(0xD70015, 0.08), dark: .init(0xFF453A, 0.2))
  public static let dangerSurfacePressed = Color.adaptive(light: .init(0xD70015, 0.16), dark: .init(0xFF453A, 0.36))
  public static let success = Color.adaptive(light: .init(0x008009), dark: .init(0x30D158))
  public static let successSurface = Color.adaptive(light: .init(0xEFFAF1), dark: .init(0x30D158, 0.16))
  public static let warning = Color.adaptive(light: .init(0xB64400), dark: .init(0xFF9F0A))
  public static let warningSurface = Color.adaptive(light: .init(0xFFF5EA), dark: .init(0xFF9F0A, 0.16))
}

public enum Radius {
  public static let xs: CGFloat = 3
  public static let sm: CGFloat = 5
  public static let md: CGFloat = 8
  public static let lg: CGFloat = 10
  public static let xl: CGFloat = 12
  public static let xl2: CGFloat = 18
  public static let xl3: CGFloat = 28
  public static let full: CGFloat = 9999
}

extension TextStyle {
  public static let xs3 = TextStyle(size: 11, lineHeight: 13, letterSpacing: 0.066)
  public static let xs2 = TextStyle(size: 12, lineHeight: 16, letterSpacing: 0)
  public static let xs = TextStyle(size: 13, lineHeight: 18, letterSpacing: -0.078)
  public static let sm = TextStyle(size: 15, lineHeight: 20, letterSpacing: -0.24)
  public static let md = TextStyle(size: 16, lineHeight: 21, letterSpacing: -0.32)
  public static let base = TextStyle(size: 17, lineHeight: 22, letterSpacing: -0.442)
  public static let lg = TextStyle(size: 20, lineHeight: 25, letterSpacing: -0.46)
  public static let xl = TextStyle(size: 22, lineHeight: 28, letterSpacing: -0.264)
  public static let xl2 = TextStyle(size: 28, lineHeight: 34, letterSpacing: 0.392)
  public static let xl3 = TextStyle(size: 34, lineHeight: 41, letterSpacing: 0.408)

  public static let allCases: [TextStyle] = [.xs3, .xs2, .xs, .sm, .md, .base, .lg, .xl, .xl2, .xl3]
}

extension Shadow {
  public static let md = Shadow(color: .black.opacity(0.08), radius: 6, x: 2, y: 4)
  public static let lg = Shadow(color: .black.opacity(0.12), radius: 12, y: 4)
}
```

4. Copy the source:

```swift title="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
}

struct MessageScrollerTracker {
  var ids: [AnyHashable] = []
  var atEnd = true
  var atStart = true
  var unread = 0

  mutating func update(_ next: [AnyHashable]) -> MessageScrollerChange {
    defer { ids = next }
    guard ids != next else { return MessageScrollerChange() }
    guard let first = ids.first, let last = ids.last else {
      unread = 0
      return MessageScrollerChange(reset: true)
    }
    guard let lastIndex = next.lastIndex(of: last) else {
      unread = 0
      return MessageScrollerChange(reset: true)
    }
    let appended = next.count - 1 - lastIndex
    let prepended = next.firstIndex(of: first) ?? 0
    if appended > 0 && !atEnd {
      unread += appended
    }
    return MessageScrollerChange(appended: appended, prepended: prepended)
  }

  mutating func setEdges(atStart: Bool, atEnd: Bool) {
    self.atStart = atStart
    self.atEnd = atEnd
    if atEnd { unread = 0 }
  }
}

struct MessageScrollerRequest: Equatable {
  enum Target: Equatable {
    case start, end
    case message(AnyHashable, UnitPoint)
  }

  let serial: Int
  let target: Target
  let animated: Bool
}

@MainActor
@Observable
public final class MessageScrollerController {
  var tracker = MessageScrollerTracker()
  var request: MessageScrollerRequest?
  private var serial = 0

  public init() {}

  public var unreadCount: Int { tracker.unread }
  public var isAtEnd: Bool { tracker.atEnd }
  public var isAtStart: Bool { tracker.atStart }

  public func canScroll(_ direction: MessageScrollerScrollDirection) -> Bool {
    direction == .end ? !tracker.atEnd : !tracker.atStart
  }

  public func scrollToEnd(animated: Bool = true) {
    send(.end, animated: animated)
  }

  public func scrollToStart(animated: Bool = true) {
    send(.start, animated: animated)
  }

  public func scrollToMessage<ID: Hashable>(_ id: ID, anchor: UnitPoint = .top, animated: Bool = true) {
    send(.message(AnyHashable(id), anchor), animated: animated)
  }

  func update(ids: [AnyHashable]) -> MessageScrollerChange {
    tracker.update(ids)
  }

  func setEdges(atStart: Bool, atEnd: Bool) {
    guard atStart != tracker.atStart || atEnd != tracker.atEnd || (atEnd && tracker.unread > 0) else { return }
    tracker.setEdges(atStart: atStart, atEnd: atEnd)
  }

  private func send(_ target: MessageScrollerRequest.Target, animated: Bool) {
    serial += 1
    request = MessageScrollerRequest(serial: serial, target: target, animated: animated)
  }
}

struct MessageScrollerMetrics: Equatable {
  var minY: CGFloat = 0
  var maxY: CGFloat = 0
}

public struct MessageScroller<Data: RandomAccessCollection, ID: Hashable, Row: View>: View {
  let data: Data
  let id: KeyPath<Data.Element, ID>
  let spacing: CGFloat
  let defaultScrollPosition: MessageScrollerDefaultScrollPosition
  let autoScroll: Bool
  let scrollEdgeThreshold: CGFloat
  let buttons: Set<MessageScrollerScrollDirection>
  let controller: MessageScrollerController?
  let onLoadOlder: (() -> Void)?
  let row: (Data.Element) -> Row

  @State private var own = MessageScrollerController()
  @State private var position: ID?
  @State private var requestedOlder: ID?
  @State private var metrics = MessageScrollerMetrics()
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init(
    _ data: Data,
    id: KeyPath<Data.Element, ID>,
    spacing: CGFloat = 12,
    defaultScrollPosition: MessageScrollerDefaultScrollPosition = .end,
    autoScroll: Bool = true,
    scrollEdgeThreshold: CGFloat = 8,
    buttons: Set<MessageScrollerScrollDirection> = [.end],
    controller: MessageScrollerController? = nil,
    onLoadOlder: (() -> Void)? = nil,
    @ViewBuilder row: @escaping (Data.Element) -> Row
  ) {
    self.data = data
    self.id = id
    self.spacing = spacing
    self.defaultScrollPosition = defaultScrollPosition
    self.autoScroll = autoScroll
    self.scrollEdgeThreshold = scrollEdgeThreshold
    self.buttons = buttons
    self.controller = controller
    self.onLoadOlder = onLoadOlder
    self.row = row
  }

  public var body: some View {
    let controller = controller ?? own
    let ids = data.map { $0[keyPath: id] }
    let space = "messageScroller"
    GeometryReader { viewport in
      ScrollViewReader { proxy in
        ScrollView(.vertical) {
          LazyVStack(spacing: spacing) {
            ForEach(data, id: id) { element in
              let key = element[keyPath: id]
              row(element)
                .padding(.horizontal, 10)
                .frame(maxWidth: .infinity)
                .onAppear {
                  if key == ids.first { loadOlder(key) }
                }
            }
          }
          .padding(.vertical, 16)
          .scrollTargetLayout()
          .onGeometryChange(for: MessageScrollerMetrics.self) { content in
            let frame = content.frame(in: .named(space))
            return MessageScrollerMetrics(minY: frame.minY, maxY: frame.maxY)
          } action: { value in
            metrics = value
            updateEdges(controller, metrics: value, viewport: viewport.size.height)
          }
        }
        .coordinateSpace(.named(space))
        .defaultScrollAnchor(defaultScrollPosition == .end ? .bottom : .top)
        .scrollPosition(id: $position)
        .scrollDismissesKeyboard(.interactively)
        .accessibilityLabel(Text("Messages"))
        .onChange(of: ids, initial: true) { _, next in
          let wasAtEnd = controller.isAtEnd
          let change = controller.update(ids: next.map { AnyHashable($0) })
          if change.reset {
            if defaultScrollPosition == .end, let last = next.last {
              proxy.scrollTo(last, anchor: .bottom)
            }
          } else if change.appended > 0 && wasAtEnd && autoScroll, let last = next.last {
            scroll(animated: true) { proxy.scrollTo(last, anchor: .bottom) }
          }
        }
        .onChange(of: viewport.size.height) {
          updateEdges(controller, metrics: metrics, viewport: viewport.size.height)
          if controller.isAtEnd && autoScroll, let last = ids.last {
            proxy.scrollTo(last, anchor: .bottom)
          }
        }
        .onChange(of: controller.request) { _, request in
          guard let request else { return }
          scroll(animated: request.animated) {
            switch request.target {
            case .end:
              if let last = ids.last { proxy.scrollTo(last, anchor: .bottom) }
            case .start:
              if let first = ids.first { proxy.scrollTo(first, anchor: .top) }
            case .message(let target, let anchor):
              proxy.scrollTo(target, anchor: anchor)
            }
          }
        }
      }
    }
    .overlay(alignment: .top) {
      if buttons.contains(.start) {
        MessageScrollerButton(direction: .start, controller: controller)
          .padding(.top, 16)
      }
    }
    .overlay(alignment: .bottom) {
      if buttons.contains(.end) {
        MessageScrollerButton(direction: .end, controller: controller)
          .padding(.bottom, 16)
      }
    }
    .environment(\.messageScroller, controller)
  }

  private func updateEdges(_ controller: MessageScrollerController, metrics: MessageScrollerMetrics, viewport: CGFloat) {
    controller.setEdges(
      atStart: metrics.minY >= -scrollEdgeThreshold,
      atEnd: metrics.maxY - viewport <= scrollEdgeThreshold
    )
  }

  private func loadOlder(_ key: ID) {
    guard let onLoadOlder, requestedOlder != key else { return }
    requestedOlder = key
    onLoadOlder()
  }

  private func scroll(animated: Bool, _ action: @escaping () -> Void) {
    if animated && !reduceMotion {
      withAnimation(.smooth(duration: 0.3)) { action() }
    } else {
      action()
    }
  }
}

extension MessageScroller where Data.Element: Identifiable, ID == Data.Element.ID {
  public init(
    _ data: Data,
    spacing: CGFloat = 12,
    defaultScrollPosition: MessageScrollerDefaultScrollPosition = .end,
    autoScroll: Bool = true,
    scrollEdgeThreshold: CGFloat = 8,
    buttons: Set<MessageScrollerScrollDirection> = [.end],
    controller: MessageScrollerController? = nil,
    onLoadOlder: (() -> Void)? = nil,
    @ViewBuilder row: @escaping (Data.Element) -> Row
  ) {
    self.init(
      data,
      id: \.id,
      spacing: spacing,
      defaultScrollPosition: defaultScrollPosition,
      autoScroll: autoScroll,
      scrollEdgeThreshold: scrollEdgeThreshold,
      buttons: buttons,
      controller: controller,
      onLoadOlder: onLoadOlder,
      row: row
    )
  }
}

public struct MessageScrollerButton: View {
  let direction: MessageScrollerScrollDirection
  let controller: MessageScrollerController?

  @Environment(\.messageScroller) private var environment
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init(direction: MessageScrollerScrollDirection = .end, controller: MessageScrollerController? = nil) {
    self.direction = direction
    self.controller = controller
  }

  public var body: some View {
    let controller = controller ?? environment
    let active = controller?.canScroll(direction) ?? false
    let unread = direction == .end ? controller?.unreadCount ?? 0 : 0
    Button {
      guard let controller, active else { return }
      if direction == .end {
        controller.scrollToEnd()
      } else {
        controller.scrollToStart()
      }
    } label: {
      Ph.arrowDown.bold
        .resizable()
        .scaledToFit()
        .frame(width: 16, height: 16)
        .rotationEffect(.degrees(direction == .start ? 180 : 0))
    }
    .buttonStyle(MessageScrollerButtonStyle())
    .overlay(alignment: .topTrailing) {
      if unread > 0 {
        Text(unread > 99 ? "99+" : "\(unread)")
          .font(TextStyle.xs2.font(weight: .semibold))
          .monospacedDigit()
          .lineLimit(1)
          .fixedSize()
          .foregroundStyle(Color.onAccent)
          .padding(.horizontal, 5)
          .frame(minWidth: 18, minHeight: 18)
          .background(Color.accent, in: Capsule())
          .background {
            Capsule().fill(Color.surfaceRaised).padding(-2)
          }
          .offset(x: 6, y: -6)
          .accessibilityHidden(true)
      }
    }
    .scaleEffect(active ? 1 : 0.95)
    .opacity(active ? 1 : 0)
    .offset(y: active ? 0 : (direction == .end ? 32 : -32))
    .animation(reduceMotion ? nil : .timingCurve(0.23, 1, 0.32, 1, duration: active ? 0.2 : 0.4), value: active)
    .allowsHitTesting(active)
    .accessibilityHidden(!active)
    .accessibilityLabel(Text(direction == .end ? "Scroll to end" : "Scroll to start"))
    .accessibilityValue(unread > 0 ? Text("\(unread) new") : Text(verbatim: ""))
  }
}

private struct MessageScrollerButtonStyle: ButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    configuration.label
      .foregroundStyle(Color.label)
      .frame(width: 32, height: 32)
      .background {
        Circle()
          .fill(Color.surfaceRaised)
          .overlay {
            if configuration.isPressed {
              Circle().fill(Color.controlPressed)
            }
          }
      }
      .overlay {
        Circle().strokeBorder(Color.separator, lineWidth: 1)
      }
      .shadow(.md)
      .contentShape(Circle())
  }
}

extension EnvironmentValues {
  @Entry var messageScroller: MessageScrollerController? = nil
}
```

5. Add the files to your app target.

**Jetpack Compose**

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

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

2. Copy the theme files every view uses:

```kt title="kotlin/theme/Theme.kt"
package com.esuiss.ui.theme

import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.dropShadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.shadow.Shadow as DropShadow
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp

internal fun textStyle(size: Double, lineHeight: Double, letterSpacing: Double) = TextStyle(
    fontSize = size.sp,
    lineHeight = lineHeight.sp,
    letterSpacing = letterSpacing.em,
    lineHeightStyle = LineHeightStyle(
        alignment = LineHeightStyle.Alignment.Center,
        trim = LineHeightStyle.Trim.None,
    ),
)

@Immutable
data class Shadow(
    val offsetX: Dp,
    val offsetY: Dp,
    val blur: Dp,
    val color: Color,
)

fun Modifier.shadow(shadow: Shadow, shape: Shape): Modifier = dropShadow(
    shape,
    DropShadow(
        radius = shadow.blur,
        color = shadow.color,
        offset = DpOffset(shadow.offsetX, shadow.offsetY),
    ),
)

val LocalColors = staticCompositionLocalOf { LightColors }

object EsuissTheme {
    val colors: Colors
        @Composable get() = LocalColors.current

    val typography: Typography
        get() = Typography

    val radius: Radius
        get() = Radius

    val shadows: Shadows
        get() = Shadows
}

@Composable
fun EsuissTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    content: @Composable () -> Unit,
) {
    CompositionLocalProvider(
        LocalColors provides if (darkTheme) DarkColors else LightColors,
        content = content,
    )
}
```

```kt title="kotlin/theme/Tokens.kt"
package com.esuiss.ui.theme

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

@Immutable
data class Colors(
    val surface: Color,
    val surfaceSecondary: Color,
    val surfaceTertiary: Color,
    val surfaceRaised: Color,
    val label: Color,
    val labelSecondary: Color,
    val labelTertiary: Color,
    val labelQuaternary: Color,
    val control: Color,
    val controlPressed: Color,
    val controlSelected: Color,
    val controlDisabled: Color,
    val onControlSelected: Color,
    val itemPressed: Color,
    val itemSelected: Color,
    val separator: Color,
    val separatorStrong: Color,
    val accent: Color,
    val accentPressed: Color,
    val accentSelected: Color,
    val accentDisabled: Color,
    val onAccent: Color,
    val accentSurface: Color,
    val accentSurfacePressed: Color,
    val link: Color,
    val focus: Color,
    val scrim: Color,
    val media: Color,
    val onMedia: Color,
    val danger: Color,
    val dangerSurface: Color,
    val dangerSurfacePressed: Color,
    val success: Color,
    val successSurface: Color,
    val warning: Color,
    val warningSurface: Color,
    val blue: Color,
    val green: Color,
    val orange: Color,
    val red: Color,
    val yellow: Color,
    val purple: Color,
    val pink: Color,
    val indigo: Color,
    val teal: Color,
    val mint: Color,
    val cyan: Color,
    val brown: Color,
    val gray: Color,
    val isDark: Boolean,
)

val LightColors = Colors(
    surface = Color(0xFFFFFFFF),
    surfaceSecondary = Color(0xFFF5F5F7),
    surfaceTertiary = Color(0xFFE8E8ED),
    surfaceRaised = Color(0xFFFFFFFF),
    label = Color(0xFF1D1D1F),
    labelSecondary = Color(0xFF6E6E73),
    labelTertiary = Color(0xFF86868B),
    labelQuaternary = Color(0xFFD2D2D7),
    control = Color(0xFF000000).copy(alpha = 0.04f),
    controlPressed = Color(0xFF000000).copy(alpha = 0.12f),
    controlSelected = Color(0xFF1D1D1F),
    controlDisabled = Color(0xFF000000).copy(alpha = 0.03f),
    onControlSelected = Color(0xFFFFFFFF),
    itemPressed = Color(0xFF000000).copy(alpha = 0.08f),
    itemSelected = Color(0xFF000000).copy(alpha = 0.06f),
    separator = Color(0xFFD2D2D7),
    separatorStrong = Color(0xFF86868B),
    accent = Color(0xFF0071E3),
    accentPressed = Color(0xFF005BB8),
    accentSelected = Color(0xFF0071E3),
    accentDisabled = Color(0xFF0071E3).copy(alpha = 0.4f),
    onAccent = Color(0xFFFFFFFF),
    accentSurface = Color(0xFF0071E3).copy(alpha = 0.09f),
    accentSurfacePressed = Color(0xFF0071E3).copy(alpha = 0.18f),
    link = Color(0xFF0066CC),
    focus = Color(0xFF0071E3),
    scrim = Color(0xFF000000).copy(alpha = 0.48f),
    media = Color(0xFF000000),
    onMedia = Color(0xFFFFFFFF),
    danger = Color(0xFFD0000A),
    dangerSurface = Color(0xFFD70015).copy(alpha = 0.08f),
    dangerSurfacePressed = Color(0xFFD70015).copy(alpha = 0.16f),
    success = Color(0xFF008009),
    successSurface = Color(0xFFEFFAF1),
    warning = Color(0xFFB64400),
    warningSurface = Color(0xFFFFF5EA),
    blue = Color(0xFF007AFF),
    green = Color(0xFF34C759),
    orange = Color(0xFFFF9500),
    red = Color(0xFFFF3B30),
    yellow = Color(0xFFFFCC00),
    purple = Color(0xFFAF52DE),
    pink = Color(0xFFFF2D55),
    indigo = Color(0xFF5856D6),
    teal = Color(0xFF30B0C7),
    mint = Color(0xFF00C7BE),
    cyan = Color(0xFF32ADE6),
    brown = Color(0xFFA2845E),
    gray = Color(0xFF8E8E93),
    isDark = false,
)

val DarkColors = Colors(
    surface = Color(0xFF000000),
    surfaceSecondary = Color(0xFF161617),
    surfaceTertiary = Color(0xFF2C2C2E),
    surfaceRaised = Color(0xFF1D1D1F),
    label = Color(0xFFF5F5F7),
    labelSecondary = Color(0xFFA1A1A6),
    labelTertiary = Color(0xFF86868B),
    labelQuaternary = Color(0xFF424245),
    control = Color(0xFFFFFFFF).copy(alpha = 0.1f),
    controlPressed = Color(0xFFFFFFFF).copy(alpha = 0.22f),
    controlSelected = Color(0xFFF5F5F7),
    controlDisabled = Color(0xFFFFFFFF).copy(alpha = 0.05f),
    onControlSelected = Color(0xFF000000),
    itemPressed = Color(0xFFFFFFFF).copy(alpha = 0.12f),
    itemSelected = Color(0xFFFFFFFF).copy(alpha = 0.1f),
    separator = Color(0xFF424245),
    separatorStrong = Color(0xFF6E6E73),
    accent = Color(0xFF0071E3),
    accentPressed = Color(0xFF004D9E),
    accentSelected = Color(0xFF0071E3),
    accentDisabled = Color(0xFF0071E3).copy(alpha = 0.4f),
    onAccent = Color(0xFFFFFFFF),
    accentSurface = Color(0xFF0071E3).copy(alpha = 0.18f),
    accentSurfacePressed = Color(0xFF0071E3).copy(alpha = 0.3f),
    link = Color(0xFF2997FF),
    focus = Color(0xFF0071E3),
    scrim = Color(0xFF000000).copy(alpha = 0.48f),
    media = Color(0xFF000000),
    onMedia = Color(0xFFFFFFFF),
    danger = Color(0xFFFF5447),
    dangerSurface = Color(0xFFFF453A).copy(alpha = 0.2f),
    dangerSurfacePressed = Color(0xFFFF453A).copy(alpha = 0.36f),
    success = Color(0xFF30D158),
    successSurface = Color(0xFF30D158).copy(alpha = 0.16f),
    warning = Color(0xFFFF9F0A),
    warningSurface = Color(0xFFFF9F0A).copy(alpha = 0.16f),
    blue = Color(0xFF0A84FF),
    green = Color(0xFF30D158),
    orange = Color(0xFFFF9F0A),
    red = Color(0xFFFF453A),
    yellow = Color(0xFFFFD60A),
    purple = Color(0xFFBF5AF2),
    pink = Color(0xFFFF375F),
    indigo = Color(0xFF5E5CE6),
    teal = Color(0xFF40C8E0),
    mint = Color(0xFF66D4CF),
    cyan = Color(0xFF64D2FF),
    brown = Color(0xFFAC8E68),
    gray = Color(0xFF8E8E93),
    isDark = true,
)

object Radius {
    val xs = 3.dp
    val sm = 5.dp
    val md = 8.dp
    val lg = 10.dp
    val xl = 12.dp
    val xl2 = 18.dp
    val xl3 = 28.dp
    val full = 9999.dp
}

object Typography {
    val xs3 = textStyle(11.0, 13.0, 0.006)
    val xs2 = textStyle(12.0, 16.0, 0.0)
    val xs = textStyle(13.0, 18.0, -0.006)
    val sm = textStyle(15.0, 20.0, -0.016)
    val md = textStyle(16.0, 21.0, -0.02)
    val base = textStyle(17.0, 22.0, -0.026)
    val lg = textStyle(20.0, 25.0, -0.023)
    val xl = textStyle(22.0, 28.0, -0.012)
    val xl2 = textStyle(28.0, 34.0, 0.014)
    val xl3 = textStyle(34.0, 41.0, 0.012)

    val all = listOf(xs3, xs2, xs, sm, md, base, lg, xl, xl2, xl3)
}

object Shadows {
    val md = Shadow(2.dp, 4.dp, 12.dp, Color.Black.copy(alpha = 0.08f))
    val lg = Shadow(0.dp, 4.dp, 24.dp, Color.Black.copy(alpha = 0.12f))
}
```

3. Copy the source and every file it uses:

```kt title="kotlin/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
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.regular.ArrowDown
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Typography
import kotlinx.coroutines.launch

enum class MessageScrollerScrollDirection { Start, End }

private const val MessageScrollerLoader = "com.esuiss.ui.MessageScroller.loader"

private const val LoadOlderThreshold = 3

@Stable
class MessageScrollerState internal constructor(
    internal val listState: LazyListState,
    private val edgeThreshold: Float,
) {
    internal var newestKey: Any? = null
    internal var keys: List<Any> = emptyList()

    var unreadCount by mutableIntStateOf(0)
        internal set

    val isAtEnd: Boolean
        get() = listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset <= edgeThreshold

    val canScrollToEnd: Boolean
        get() = !isAtEnd

    val canScrollToStart: Boolean
        get() = listState.canScrollForward

    val visibleKeys: List<Any>
        get() = listState.layoutInfo.visibleItemsInfo.map { it.key }.filter { it != MessageScrollerLoader }.asReversed()

    suspend fun scrollToEnd(animated: Boolean = true) {
        if (animated) {
            if (listState.firstVisibleItemIndex > 12) listState.scrollToItem(12)
            listState.animateScrollToItem(0)
        } else {
            listState.scrollToItem(0)
        }
        unreadCount = 0
    }

    suspend fun scrollToStart(animated: Boolean = true) {
        val last = keys.size - 1
        if (last < 0) return
        if (animated) {
            if (last - listState.firstVisibleItemIndex > 12) listState.scrollToItem(last - 12)
            listState.animateScrollToItem(last)
        } else {
            listState.scrollToItem(last)
        }
    }

    suspend fun scrollToMessage(key: Any, animated: Boolean = true): Boolean {
        val index = keys.indexOf(key)
        if (index < 0) return false
        if (animated) listState.animateScrollToItem(index) else listState.scrollToItem(index)
        return true
    }
}

@Composable
fun rememberMessageScrollerState(): MessageScrollerState {
    val listState = rememberLazyListState()
    val threshold = with(LocalDensity.current) { 8.dp.toPx() }
    return remember(listState, threshold) { MessageScrollerState(listState, threshold) }
}

private val LocalMessageScroller = staticCompositionLocalOf<MessageScrollerState?> { null }

@Composable
fun <T> MessageScroller(
    items: List<T>,
    key: (T) -> Any,
    modifier: Modifier = Modifier,
    state: MessageScrollerState = rememberMessageScrollerState(),
    onLoadOlder: (() -> Unit)? = null,
    loadingOlder: Boolean = false,
    contentPadding: PaddingValues = PaddingValues(16.dp),
    verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(12.dp, Alignment.Bottom),
    overlay: @Composable BoxScope.() -> Unit = { MessageScrollerButton() },
    content: @Composable LazyItemScope.(T) -> Unit,
) {
    val newest = items.lastOrNull()?.let(key)
    SideEffect {
        state.keys = items.asReversed().map(key)
        val previous = state.newestKey
        if (previous != null && newest != null && previous != newest) {
            val at = items.indexOfLast { key(it) == previous }
            if (at >= 0) {
                if (state.isAtEnd) {
                    state.listState.requestScrollToItem(0)
                } else {
                    state.unreadCount += items.size - 1 - at
                }
            }
        }
        state.newestKey = newest
    }
    LaunchedEffect(state) {
        snapshotFlow { state.isAtEnd }.collect { if (it) state.unreadCount = 0 }
    }
    val load by rememberUpdatedState(onLoadOlder)
    val loading by rememberUpdatedState(loadingOlder)
    val count by rememberUpdatedState(items.size)
    LaunchedEffect(state) {
        snapshotFlow {
            val info = state.listState.layoutInfo
            val last = info.visibleItemsInfo.lastOrNull { it.key != MessageScrollerLoader }?.index ?: -1
            if (count > 0 && last >= count - 1 - LoadOlderThreshold) count else -1
        }.collect { size ->
            if (size >= 0 && !loading) load?.invoke()
        }
    }
    Box(modifier) {
        LazyColumn(
            modifier = Modifier.fillMaxSize(),
            state = state.listState,
            contentPadding = contentPadding,
            reverseLayout = true,
            verticalArrangement = verticalArrangement,
        ) {
            items(items.asReversed(), key = key) { item ->
                content(item)
            }
            if (loadingOlder) {
                item(key = MessageScrollerLoader) {
                    Box(Modifier.fillMaxWidth().padding(vertical = 8.dp), contentAlignment = Alignment.Center) {
                        Spinner(size = 20.dp)
                    }
                }
            }
        }
        CompositionLocalProvider(LocalMessageScroller provides state) {
            overlay()
        }
    }
}

private val EnterEasing = CubicBezierEasing(0.23f, 1f, 0.32f, 1f)

private val ExitEasing = CubicBezierEasing(0.7f, 0f, 0.84f, 0f)

@Composable
fun BoxScope.MessageScrollerButton(
    modifier: Modifier = Modifier,
    direction: MessageScrollerScrollDirection = MessageScrollerScrollDirection.End,
    animated: Boolean = true,
    contentDescription: String = if (direction == MessageScrollerScrollDirection.End) "Scroll to end" else "Scroll to start",
) {
    val state = LocalMessageScroller.current ?: return
    val colors = EsuissTheme.colors
    val scope = rememberCoroutineScope()
    val end = direction == MessageScrollerScrollDirection.End
    val active by remember(state, end) { derivedStateOf { if (end) state.canScrollToEnd else state.canScrollToStart } }
    val progress by animateFloatAsState(
        targetValue = if (active) 1f else 0f,
        animationSpec = if (active) tween(200, easing = EnterEasing) else tween(400, easing = ExitEasing),
        label = "message-scroller-button",
    )
    if (!active && progress == 0f) return
    val unread = if (end) state.unreadCount else 0
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val scroll = {
        scope.launch {
            if (end) state.scrollToEnd(animated) else state.scrollToStart(animated)
        }
        Unit
    }
    val description = if (unread > 0) "$contentDescription, $unread new" else contentDescription
    Box(
        modifier = modifier
            .align(if (end) Alignment.BottomCenter else Alignment.TopCenter)
            .padding(vertical = 16.dp)
            .graphicsLayer {
                alpha = progress
                val scale = 0.95f + 0.05f * progress
                scaleX = scale
                scaleY = scale
                translationY = (1f - progress) * size.height * (if (end) 1f else -1f)
            }
            .clearAndSetSemantics {
                if (active) {
                    this.contentDescription = description
                    role = Role.Button
                    onClick {
                        scroll()
                        true
                    }
                }
            },
    ) {
        Box(
            modifier = Modifier
                .size(32.dp)
                .clip(CircleShape)
                .background(if (pressed) lerp(colors.surface, colors.label, 0.1f) else colors.surface, CircleShape)
                .border(1.dp, colors.separator, CircleShape)
                .clickable(
                    interactionSource = interaction,
                    indication = null,
                    enabled = active,
                    onClick = scroll,
                ),
            contentAlignment = Alignment.Center,
        ) {
            Icon(
                PhosphorIcons.Regular.ArrowDown,
                contentDescription = null,
                modifier = if (end) Modifier else Modifier.rotate(180f),
                size = 16.dp,
                tint = colors.label,
            )
        }
        if (unread > 0) {
            Box(
                modifier = Modifier
                    .align(Alignment.TopEnd)
                    .offset(x = 8.dp, y = (-6).dp)
                    .background(colors.surface, CircleShape)
                    .padding(2.dp)
                    .defaultMinSize(minWidth = 18.dp, minHeight = 18.dp)
                    .background(colors.accent, CircleShape)
                    .padding(horizontal = 5.dp),
                contentAlignment = Alignment.Center,
            ) {
                BasicText(
                    if (unread > 99) "99+" else unread.toString(),
                    style = Typography.xs2.copy(color = colors.onAccent, fontWeight = FontWeight.SemiBold),
                    maxLines = 1,
                )
            }
        }
    }
}
```

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

import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.esuiss.ui.theme.Colors
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography

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

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

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

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

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

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

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

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

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

import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.drawOutline
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.layout.layout
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.layout.onPlaced
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.constrainHeight
import androidx.compose.ui.unit.constrainWidth
import androidx.compose.ui.unit.dp
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography

enum class ButtonGroupOrientation { Horizontal, Vertical }

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

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

internal object ButtonGroupFill

private object ButtonGroupDivider

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

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

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

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

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

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

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

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

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

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

val LocalContentStyle = compositionLocalOf { TextStyle.Default }

val LocalContentColor = compositionLocalOf { Color.Unspecified }

val LocalIconSize = compositionLocalOf { 20.dp }
```

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

import android.provider.Settings
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.constrainHeight
import androidx.compose.ui.unit.constrainWidth
import androidx.compose.ui.unit.dp
import com.esuiss.ui.theme.Colors
import com.esuiss.ui.theme.Shadow

internal val FormShadowXs = Shadow(0.dp, 1.dp, 2.dp, Color.Black.copy(alpha = 0.05f))

internal val FormShadowSm = Shadow(0.dp, 1.dp, 3.dp, Color.Black.copy(alpha = 0.1f))

internal val FormEasing = CubicBezierEasing(0.32f, 0.72f, 0f, 1f)

internal fun Modifier.formTouchTarget(): Modifier = layout { measurable, constraints ->
    val minimum = 48.dp.roundToPx()
    val placeable = measurable.measure(constraints)
    val width = constraints.constrainWidth(maxOf(placeable.width, minimum))
    val height = constraints.constrainHeight(maxOf(placeable.height, minimum))
    layout(width, height) {
        placeable.place((width - placeable.width) / 2, (height - placeable.height) / 2)
    }
}

@Composable
internal fun formReducedMotion(): Boolean {
    val context = LocalContext.current
    return remember(context) {
        Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f
    }
}

internal fun formSelectionColors(colors: Colors) = TextSelectionColors(
    handleColor = colors.accent,
    backgroundColor = colors.accent.copy(alpha = 0.3f),
)

internal fun Modifier.formInputSurface(
    colors: Colors,
    shape: Shape,
    focused: Boolean,
    invalid: Boolean,
    enabled: Boolean,
): Modifier {
    val background = when {
        !enabled -> colors.controlDisabled
        invalid -> colors.danger.copy(alpha = if (colors.isDark) 0.1f else 0.05f)
        else -> colors.control
    }
    val border = when {
        invalid -> colors.danger
        focused -> colors.focus
        else -> Color.Transparent
    }
    return clip(shape).background(background, shape).border(1.dp, border, shape)
}

@Composable
internal fun FormDividedStack(
    orientation: Orientation,
    dividerColor: Color,
    modifier: Modifier = Modifier,
    dividerInsetStart: Dp = 0.dp,
    content: @Composable () -> Unit,
) {
    val boundaries = remember { mutableStateOf(IntArray(0)) }
    val vertical = orientation == Orientation.Vertical
    Layout(
        content = content,
        modifier = modifier.drawWithContent {
            drawContent()
            val stroke = 1.dp.toPx()
            val inset = dividerInsetStart.toPx()
            boundaries.value.forEach { position ->
                if (vertical) {
                    val start = if (layoutDirection == LayoutDirection.Rtl) 0f else inset
                    drawRect(dividerColor, Offset(start, position.toFloat()), Size(size.width - inset, stroke))
                } else {
                    val x = if (layoutDirection == LayoutDirection.Rtl) size.width - position - stroke else position.toFloat()
                    drawRect(dividerColor, Offset(x, 0f), Size(stroke, size.height))
                }
            }
        },
    ) { measurables, constraints ->
        if (vertical) {
            val width = if (constraints.hasFixedWidth) {
                constraints.maxWidth
            } else {
                constraints.constrainWidth(measurables.maxOfOrNull { it.maxIntrinsicWidth(Constraints.Infinity) } ?: 0)
            }
            val placeables = measurables.map {
                it.measure(Constraints(minWidth = width, maxWidth = width))
            }
            val offsets = IntArray(placeables.size)
            var y = 0
            placeables.forEachIndexed { index, placeable ->
                offsets[index] = y
                y += placeable.height
            }
            val next = offsets.drop(1).toIntArray()
            if (!next.contentEquals(boundaries.value)) boundaries.value = next
            layout(width, constraints.constrainHeight(y)) {
                placeables.forEachIndexed { index, placeable -> placeable.placeRelative(0, offsets[index]) }
            }
        } else {
            val height = if (constraints.hasFixedHeight) {
                constraints.maxHeight
            } else {
                constraints.constrainHeight(measurables.maxOfOrNull { it.maxIntrinsicHeight(Constraints.Infinity) } ?: 0)
            }
            val placeables = measurables.map {
                it.measure(Constraints(minHeight = height, maxHeight = height))
            }
            val offsets = IntArray(placeables.size)
            var x = 0
            placeables.forEachIndexed { index, placeable ->
                offsets[index] = x
                x += placeable.width
            }
            val next = offsets.drop(1).toIntArray()
            if (!next.contentEquals(boundaries.value)) boundaries.value = next
            layout(constraints.constrainWidth(x), height) {
                placeables.forEachIndexed { index, placeable -> placeable.placeRelative(offsets[index], 0) }
            }
        }
    }
}
```

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

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

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

```kt title="kotlin/components/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
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.ProgressBarRangeInfo
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.progressBarRangeInfo
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.esuiss.ui.theme.EsuissTheme
import kotlin.math.floor

internal const val SpinnerSpokes = 8

internal fun spinnerSpokeOpacity(index: Int): Float = 0.2f + 0.8f * index / (SpinnerSpokes - 1)

internal fun DrawScope.drawSpinnerSpokes(color: Color, rotation: Float = 0f, opacity: (Int) -> Float) {
    val unit = size.minDimension / 24f
    val center = Offset(size.width / 2f, size.height / 2f)
    rotate(rotation, center) {
        for (index in 0 until SpinnerSpokes) {
            val alpha = opacity(index).coerceIn(0f, 1f)
            if (alpha <= 0f) continue
            rotate(index * 360f / SpinnerSpokes, center) {
                drawRoundRect(
                    color = color,
                    topLeft = Offset(center.x - 1.56f * unit, center.y - 11.5f * unit),
                    size = Size(3.12f * unit, 7.2f * unit),
                    cornerRadius = CornerRadius(1.56f * unit),
                    alpha = alpha * color.alpha,
                )
            }
        }
    }
}

@Composable
fun Spinner(
    modifier: Modifier = Modifier,
    size: Dp = 16.dp,
    color: Color = Color.Unspecified,
    contentDescription: String = "Loading",
) {
    val tint = color.takeOrElse { LocalContentColor.current.takeOrElse { EsuissTheme.colors.label } }
    val reduced = formReducedMotion()
    val step: State<Float> = if (reduced) {
        remember { mutableFloatStateOf(0f) }
    } else {
        rememberInfiniteTransition(label = "spinner").animateFloat(
            initialValue = 0f,
            targetValue = SpinnerSpokes.toFloat(),
            animationSpec = infiniteRepeatable(tween(800, easing = LinearEasing), RepeatMode.Restart),
            label = "step",
        )
    }
    Canvas(
        modifier = modifier
            .size(size)
            .semantics {
                this.contentDescription = contentDescription
                progressBarRangeInfo = ProgressBarRangeInfo.Indeterminate
                liveRegion = LiveRegionMode.Polite
            },
    ) {
        val rotation = (floor(step.value) % SpinnerSpokes) * 360f / SpinnerSpokes
        drawSpinnerSpokes(tint, rotation) { spinnerSpokeOpacity(it) }
    }
}
```

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

## Usage

```tsx
import { MessageScrollerProvider, MessageScroller, MessageScrollerViewport, MessageScrollerItem, MessageScrollerButton } from "@/components/ui/message-scroller"
```

```tsx
<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.

![Preview](https://uim.suiss.app/previews/components-message-scroller--unread-while-scrolled-up.light.webp)

```tsx
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">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-accent" />
              <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
        )
      }}
    />
  )
}
```

```swift
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(Color.accent)
            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: .red)
      }
      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]]
    )
  }
}
```

```kotlin
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 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 = colors.red)
                    }
                    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.accent)
                    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`.

![Preview](https://uim.suiss.app/previews/components-message-scroller--empty-thread.light.webp)

```tsx
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">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-accent" />
              <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>
      }
    />
  )
}
```

```swift
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(Color.accent)
            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: .red)
      }
      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()
    }
  }
}
```

```kotlin
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 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 = colors.red)
                    }
                    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.accent)
                    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.

![Preview](https://uim.suiss.app/previews/components-message-scroller--load-older.light.webp)

```tsx
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">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-accent" />
              <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 />
  )
}
```

```swift
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(Color.accent)
            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: .red)
      }
      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]]
    )
  }
}
```

```kotlin
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 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 = colors.red)
                    }
                    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.accent)
                    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

### MessageScrollerProvider

Shares one scroll controller with components outside the scroller.

### MessageScroller

Container that clips the list and positions the button.

### MessageScrollerViewport

Inverted `FlatList` that renders the messages.

### MessageScrollerItem

Row wrapper with horizontal padding.

### MessageScrollerButton

Floating button that scrolls to the newest or oldest message.

## 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-surface-raised` with `border-separator`, and the badge uses `bg-accent` with `text-on-accent`.
- Light and dark colors follow the system appearance.
