# Message Scroller

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

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

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

**React**

**Example**

```tsx
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Message, MessageContent } from "@/components/ui/message"
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from "@/components/ui/message-scroller"

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

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

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

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

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

## Installation

### CLI

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

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

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

### Manual

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

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

2. Add the pieces it builds on: [Button](https://ui.suiss.app/docs/components/button), [Scroll Area](https://ui.suiss.app/docs/components/scroll-area), `use-message-scroller`.

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

```tsx title="components/ui/message-scroller.tsx"
"use client"

import { mergeProps } from "@base-ui/react/merge-props"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { useRender } from "@base-ui/react/use-render"
import { ArrowDownIcon } from "@phosphor-icons/react"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { ScrollBar } from "@/components/ui/scroll-area"
import {
  MessageScrollerContext,
  type MessageScrollerOptions,
  type MessageScrollerScrollDirection,
  useChildListObserver,
  useFrameThrottledResizeObserver,
  useMergedRefs,
  useMessageScroller,
  useMessageScrollerCanScroll,
  useMessageScrollerContext,
  useMessageScrollerController,
  useMessageScrollerPendingScroll,
  useMessageScrollerScrollable,
  useMessageScrollerVisibility,
} from "@/hooks/use-message-scroller"
import { cn } from "@/lib/utils"

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

function MessageScroller({
  className,
  ref,
  ...props
}: React.ComponentProps<"div">) {
  const { setRootElement } = useMessageScrollerContext()
  const isPendingScroll = useMessageScrollerPendingScroll()
  const rootRef = useMergedRefs(setRootElement, ref)

  return (
    <ScrollAreaPrimitive.Root
      ref={rootRef}
      data-slot="message-scroller"
      className={cn(
        "group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden",
        className
      )}
      {...props}
      data-pending-scroll={isPendingScroll ? "" : undefined}
    />
  )
}

function MessageScrollerViewport({
  className,
  preserveScrollOnPrepend = true,
  ref,
  role = "region",
  "aria-label": ariaLabel = "Messages",
  tabIndex = 0,
  onKeyDown,
  onScroll,
  onTouchMove,
  onWheel,
  ...props
}: React.ComponentProps<"div"> & { preserveScrollOnPrepend?: boolean }) {
  const controller = useMessageScrollerContext()
  const isPendingScroll = useMessageScrollerPendingScroll()
  const viewportElementRef = React.useRef<HTMLDivElement>(null)
  const viewportRef = useMergedRefs(
    viewportElementRef,
    controller.setViewportElement,
    ref
  )
  controller.preserveScrollOnPrepend = preserveScrollOnPrepend
  useFrameThrottledResizeObserver(viewportElementRef, controller.handleResize)

  return (
    <>
      <ScrollAreaPrimitive.Viewport
        ref={viewportRef}
        data-slot="message-scroller-viewport"
        role={role}
        aria-label={ariaLabel}
        tabIndex={tabIndex}
        className={cn(
          "size-full min-h-0 min-w-0 overscroll-contain contain-content outline-none focus-visible:ring-3 focus-visible:ring-ring/50 data-scrollable:scroll-fade-b data-pending-scroll:invisible",
          className
        )}
        onKeyDown={(event) => {
          controller.handleKeyboardScrollIntent(event.key)
          onKeyDown?.(event)
        }}
        onScroll={(event) => {
          controller.syncAfterScroll()
          onScroll?.(event)
        }}
        onTouchMove={(event) => {
          controller.handleUserScrollIntent()
          onTouchMove?.(event)
        }}
        onWheel={(event) => {
          controller.handleUserScrollIntent()
          onWheel?.(event)
        }}
        {...props}
        data-pending-scroll={isPendingScroll ? "" : undefined}
      />
      <ScrollBar className="transition-opacity duration-200 group-has-data-autoscrolling/message-scroller:opacity-0" />
    </>
  )
}

function MessageScrollerContent({
  className,
  children,
  ref,
  role = "log",
  "aria-relevant": ariaRelevant = "additions",
  spacerClassName,
  ...props
}: React.ComponentProps<"div"> & { spacerClassName?: string }) {
  const controller = useMessageScrollerContext()
  const contentElementRef = React.useRef<HTMLDivElement>(null)
  const contentRef = useMergedRefs(
    contentElementRef,
    controller.setContentElement,
    ref
  )
  useChildListObserver(contentElementRef, controller.handleContentChange)
  useFrameThrottledResizeObserver(contentElementRef, controller.handleResize)

  return (
    <div
      ref={contentRef}
      data-slot="message-scroller-content"
      role={role}
      aria-relevant={ariaRelevant}
      className={cn("flex h-max min-h-full flex-col gap-8", className)}
      {...props}
    >
      {children}
      <div
        ref={controller.setSpacerElement}
        aria-hidden="true"
        data-message-scroller-spacer=""
        hidden
        className={spacerClassName}
      />
    </div>
  )
}

function MessageScrollerItem({
  className,
  messageId,
  scrollAnchor = false,
  ref,
  ...props
}: React.ComponentProps<"div"> & {
  messageId?: string
  scrollAnchor?: boolean
}) {
  const { registerMessage } = useMessageScrollerContext()
  const itemElementRef = React.useRef<HTMLDivElement | null>(null)
  const registerItemElement = React.useCallback(
    (element: HTMLDivElement | null) => {
      const previousElement = itemElementRef.current
      itemElementRef.current = element
      if (messageId) registerMessage(messageId, element, previousElement)
    },
    [messageId, registerMessage]
  )
  const itemRef = useMergedRefs(registerItemElement, ref)

  return (
    <div
      ref={itemRef}
      data-slot="message-scroller-item"
      data-message-id={messageId}
      data-scroll-anchor={scrollAnchor ? "true" : "false"}
      className={cn(
        "min-w-0 shrink-0 px-2.5 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]",
        className
      )}
      {...props}
    />
  )
}

type MessageScrollerButtonState = {
  active: boolean
  direction: MessageScrollerScrollDirection
}

function MessageScrollerButton({
  direction = "end",
  behavior = "smooth",
  className,
  children,
  render,
  variant = "secondary",
  size = "icon-sm",
  type = "button",
  tabIndex,
  onClick,
  ...props
}: useRender.ComponentProps<"button", MessageScrollerButtonState> &
  Pick<React.ComponentProps<typeof Button>, "variant" | "size"> & {
    behavior?: ScrollBehavior
    direction?: MessageScrollerScrollDirection
  }) {
  const { scrollToEnd, scrollToStart } = useMessageScroller()
  const active = useMessageScrollerCanScroll(direction)

  function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
    if (!active) return
    onClick?.(event)
    if (event.defaultPrevented) return
    event.currentTarget.blur()
    if (direction === "start") scrollToStart({ behavior })
    else scrollToEnd({ behavior })
  }

  const dataAttributes = {
    "data-slot": "message-scroller-button",
    "data-direction": direction,
    "data-variant": variant,
    "data-size": size,
  }

  return useRender({
    defaultTagName: "button",
    render: render ?? <Button variant={variant} size={size} />,
    state: { active, direction },
    stateAttributesMapping: {
      active: (isActive) => ({ "data-active": isActive ? "true" : "false" }),
    },
    props: mergeProps<"button">(
      {
        ...dataAttributes,
        type,
        inert: !active,
        tabIndex: active ? tabIndex : -1,
        onClick: handleClick,
        className: cn(
          "border-border bg-background text-foreground hover:bg-[color-mix(in_oklab,var(--background),var(--foreground)_6%)] hover:text-foreground absolute inset-s-1/2 -translate-x-1/2 transition-[translate,scale,opacity] duration-200 data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=false]:duration-400 data-[active=false]:ease-[cubic-bezier(0.7,0,0.84,0)] data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[active=true]:ease-[cubic-bezier(0.23,1,0.32,1)] data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full rtl:translate-x-1/2 data-[direction=start]:[&_svg]:rotate-180",
          className
        ),
        children: children ?? (
          <>
            <ArrowDownIcon />
            <span className="sr-only">
              {direction === "end" ? "Scroll to end" : "Scroll to start"}
            </span>
          </>
        ),
      },
      props
    ),
  })
}

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

4. Update the import paths to match your project.

## Usage

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

```tsx
<div className="h-96 w-96 overflow-hidden rounded-2xl border">
  <MessageScrollerProvider>
    <MessageScroller>
      <MessageScrollerViewport>
        <MessageScrollerContent className="gap-3 p-4">
          <Messages />
        </MessageScrollerContent>
      </MessageScrollerViewport>
      <MessageScrollerButton />
    </MessageScroller>
  </MessageScrollerProvider>
</div>
```

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

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

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

## Examples

### Start at top

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

**Example**

```tsx
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Message, MessageContent } from "@/components/ui/message"
import {
  MessageScroller,
  MessageScrollerButton,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from "@/components/ui/message-scroller"

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

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

export default function MessageScrollerStartAtTop() {
  return (
    <div className="h-96 w-96 overflow-hidden rounded-2xl border">
      <MessageScrollerProvider defaultScrollPosition="start">
        <MessageScroller>
          <MessageScrollerViewport>
            <MessageScrollerContent className="gap-3 p-4">
              <Messages />
            </MessageScrollerContent>
          </MessageScrollerViewport>
          <MessageScrollerButton direction="start" />
          <MessageScrollerButton />
        </MessageScroller>
      </MessageScrollerProvider>
    </div>
  )
}
```

## API Reference

- `MessageScrollerProvider`
- `MessageScroller`
  - `MessageScrollerViewport`
    - `MessageScrollerContent`
  - `MessageScrollerButton`

### MessageScrollerProvider

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

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `React.ReactNode` | - | The scroller parts and any components that use the message scroller hooks. |

Also accepts the props of `MessageScrollerOptions`.

### MessageScroller

The root container, built on a scroll area.

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

### MessageScrollerViewport

The focusable scrollable region, rendered with a vertical scrollbar.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `preserveScrollOnPrepend` | `boolean` | `true` | Keeps the visible messages in place when items are inserted before the first one, such as when loading history. |

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

### MessageScrollerContent

The live log that holds the message items.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `spacerClassName` | `string` | - | Class name for the hidden spacer element the scroller appends after the items. |

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

### MessageScrollerItem

Wraps one message and registers it with the controller.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `messageId` | `string` | - | A stable ID used for `scrollToMessage`, visibility tracking and the `data-message-id` attribute. |
| `scrollAnchor` | `boolean` | `false` | Marks the item as an anchor; when it is added, the scroller aligns it to the top of the viewport instead of the bottom. |

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

### MessageScrollerButton

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

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `behavior` | `ScrollBehavior` | `"smooth"` | The scroll behavior used when jumping, such as `smooth` or `auto`. |
| `direction` | `MessageScrollerScrollDirection` | `"end"` | Which edge the button scrolls to: `end` or `start`. It also sets the button's position and icon rotation. |

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

## Accessibility

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

## Notes

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

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.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-500">pdf</AttachmentLabel>
              </AttachmentMedia>
              <AttachmentContent>
                <AttachmentTitle>{message.attachment.name}</AttachmentTitle>
                {message.attachment.progress ? (
                  <AttachmentProgress
                    value={message.attachment.progress}
                    accessibilityLabel="Uploading"
                  />
                ) : (
                  <AttachmentDescription>
                    {message.attachment.size}
                  </AttachmentDescription>
                )}
              </AttachmentContent>
              {message.attachment.progress ? (
                <AttachmentActions>
                  <AttachmentAction accessibilityLabel="Cancel upload">
                    <Icon as={XIcon} />
                  </AttachmentAction>
                </AttachmentActions>
              ) : null}
            </Attachment>
          ) : null}
          {message.texts.map((text, index) => (
            <Bubble key={text} variant={mine ? "default" : "muted"}>
              <BubbleContent>{text}</BubbleContent>
              {message.reaction && index === message.texts.length - 1 ? (
                <BubbleReactions align={mine ? "start" : "end"}>
                  {message.reaction}
                </BubbleReactions>
              ) : null}
            </Bubble>
          ))}
          {message.status ? (
            <MessageFooter className="gap-1">
              <Icon as={ChecksIcon} className="text-primary" />
              <Text>{message.status}</Text>
            </MessageFooter>
          ) : null}
        </MessageContent>
      </Message>
    </MessageScrollerItem>
  )
}

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

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

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

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

  return null
}

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

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

export function MessageScrollerExample() {
  return (
    <Chat initial={thread} />
  )
}
```

```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(EsuissColor.primary)
            Text(verbatim: status)
          }
        }
      }
    }
  }
}

struct MessageScrollerAttachmentView: View {
  let attachment: MessageScrollerAttachment

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

struct MessageScrollerChat: View {
  let unreadDemo: Bool

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

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

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

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

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

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

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

```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 com.esuiss.ui.theme.Palette
import kotlinx.coroutines.launch

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

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

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

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

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

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

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

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

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

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

`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

**React Native**

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

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

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

**SwiftUI**

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

**Jetpack Compose**

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

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

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

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

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

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

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

4. Copy the source into `components/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,
} 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 ViewableItemsChanged<T> = Parameters<
  NonNullable<FlatListProps<T>["onViewableItemsChanged"]>
>[0]

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.at(-1)
    if (newest === undefined || keys.at(-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: ViewableItemsChanged<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} />
  )
}

function scrollToEndLabel(count: number) {
  if (count <= 0) return "Scroll to end"
  return `Scroll to end, ${count} new ${count === 1 ? "message" : "messages"}`
}

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" : scrollToEndLabel(count)

  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-border bg-card active:opacity-80 dark:bg-muted",
          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-primary px-1.5">
            <Text className="text-xs font-semibold text-primary-foreground">
              {count > 99 ? "99+" : count}
            </Text>
          </View>
        ) : null}
      </Button>
    </AnimatedView>
  )
}

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

5. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

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

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

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

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

4. Copy the source into `components/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,
} 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 ViewableItemsChanged<T> = Parameters<
  NonNullable<FlatListProps<T>["onViewableItemsChanged"]>
>[0]

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.at(-1)
    if (newest === undefined || keys.at(-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: ViewableItemsChanged<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} />
  )
}

function scrollToEndLabel(count: number) {
  if (count <= 0) return "Scroll to end"
  return `Scroll to end, ${count} new ${count === 1 ? "message" : "messages"}`
}

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" : scrollToEndLabel(count)

  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-border bg-card active:opacity-80 dark:bg-muted",
          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-primary px-1.5">
            <Text className="text-xs font-semibold text-primary-foreground">
              {count > 99 ? "99+" : count}
            </Text>
          </View>
        ) : null}
      </Button>
    </AnimatedView>
  )
}

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

5. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

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

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

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

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

4. Copy the source into `components/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,
} 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 ViewableItemsChanged<T> = Parameters<
  NonNullable<FlatListProps<T>["onViewableItemsChanged"]>
>[0]

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.xs, 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.at(-1)
    if (newest === undefined || keys.at(-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: ViewableItemsChanged<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 },
    ],
  }))

  let label = "Scroll to end"
  if (direction === "start") label = "Scroll to start"
  else if (count > 0) {
    label = `Scroll to end, ${count} new ${count === 1 ? "message" : "messages"}`
  }
  const pressedBackground = isDark ? colors.secondary : colors.muted
  const idleBackground = isDark ? colors.muted : colors.card

  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.border,
            backgroundColor: state.pressed ? pressedBackground : idleBackground,
          },
          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.primary }]}>
            <Text
              style={[styles.badgeText, { color: colors.primaryForeground }]}
            >
              {count > 99 ? "99+" : count}
            </Text>
          </View>
        ) : null}
      </Button>
    </AnimatedView>
  )
}

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

5. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

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

3. Copy the theme files every view uses:

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

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

4. Copy the source:

```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)")
          .scaledFont(TextStyle.xs, weight: .semibold)
          .monospacedDigit()
          .lineLimit(1)
          .fixedSize()
          .foregroundStyle(EsuissColor.primaryForeground)
          .padding(.horizontal, 5)
          .frame(minWidth: 18, minHeight: 18)
          .background(EsuissColor.primary, in: Capsule())
          .background {
            Capsule().fill(EsuissColor.card).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(EsuissColor.foreground)
      .frame(width: 32, height: 32)
      .background {
        Circle()
          .fill(EsuissColor.card)
      }
      .overlay {
        Circle().strokeBorder(EsuissColor.border, lineWidth: 1)
      }
      .opacity(configuration.isPressed ? 0.8 : 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:

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

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

3. Copy the source and every file it uses:

```kt title="kotlin/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.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.graphicsLayer
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)
                .alpha(if (pressed) 0.8f else 1f)
                .background(colors.background, CircleShape)
                .border(1.dp, colors.border, 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.foreground,
            )
        }
        if (unread > 0) {
            Box(
                modifier = Modifier
                    .align(Alignment.TopEnd)
                    .offset(x = 8.dp, y = (-6).dp)
                    .background(colors.background, CircleShape)
                    .padding(2.dp)
                    .defaultMinSize(minWidth = 18.dp, minHeight = 18.dp)
                    .background(colors.primary, CircleShape)
                    .padding(horizontal = 5.dp),
                contentAlignment = Alignment.Center,
            ) {
                BasicText(
                    if (unread > 99) "99+" else unread.toString(),
                    style = Typography.xs.copy(color = colors.primaryForeground, fontWeight = FontWeight.SemiBold),
                    maxLines = 1,
                )
            }
        }
    }
}
```

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

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

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

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

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

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

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://ui.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-500">pdf</AttachmentLabel>
              </AttachmentMedia>
              <AttachmentContent>
                <AttachmentTitle>{message.attachment.name}</AttachmentTitle>
                {message.attachment.progress ? (
                  <AttachmentProgress
                    value={message.attachment.progress}
                    accessibilityLabel="Uploading"
                  />
                ) : (
                  <AttachmentDescription>
                    {message.attachment.size}
                  </AttachmentDescription>
                )}
              </AttachmentContent>
              {message.attachment.progress ? (
                <AttachmentActions>
                  <AttachmentAction accessibilityLabel="Cancel upload">
                    <Icon as={XIcon} />
                  </AttachmentAction>
                </AttachmentActions>
              ) : null}
            </Attachment>
          ) : null}
          {message.texts.map((text, index) => (
            <Bubble key={text} variant={mine ? "default" : "muted"}>
              <BubbleContent>{text}</BubbleContent>
              {message.reaction && index === message.texts.length - 1 ? (
                <BubbleReactions align={mine ? "start" : "end"}>
                  {message.reaction}
                </BubbleReactions>
              ) : null}
            </Bubble>
          ))}
          {message.status ? (
            <MessageFooter className="gap-1">
              <Icon as={ChecksIcon} className="text-primary" />
              <Text>{message.status}</Text>
            </MessageFooter>
          ) : null}
        </MessageContent>
      </Message>
    </MessageScrollerItem>
  )
}

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

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

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

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

  return null
}

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

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

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

```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(EsuissColor.primary)
            Text(verbatim: status)
          }
        }
      }
    }
  }
}

struct MessageScrollerAttachmentView: View {
  let attachment: MessageScrollerAttachment

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

struct MessageScrollerChat: View {
  let unreadDemo: Bool

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

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

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

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

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

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

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

```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 com.esuiss.ui.theme.Palette
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

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

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

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

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

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

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

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

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

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

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

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

### Empty thread

An empty thread that renders an Empty state through `ListEmptyComponent`.

![Preview](https://ui.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-500">pdf</AttachmentLabel>
              </AttachmentMedia>
              <AttachmentContent>
                <AttachmentTitle>{message.attachment.name}</AttachmentTitle>
                {message.attachment.progress ? (
                  <AttachmentProgress
                    value={message.attachment.progress}
                    accessibilityLabel="Uploading"
                  />
                ) : (
                  <AttachmentDescription>
                    {message.attachment.size}
                  </AttachmentDescription>
                )}
              </AttachmentContent>
              {message.attachment.progress ? (
                <AttachmentActions>
                  <AttachmentAction accessibilityLabel="Cancel upload">
                    <Icon as={XIcon} />
                  </AttachmentAction>
                </AttachmentActions>
              ) : null}
            </Attachment>
          ) : null}
          {message.texts.map((text, index) => (
            <Bubble key={text} variant={mine ? "default" : "muted"}>
              <BubbleContent>{text}</BubbleContent>
              {message.reaction && index === message.texts.length - 1 ? (
                <BubbleReactions align={mine ? "start" : "end"}>
                  {message.reaction}
                </BubbleReactions>
              ) : null}
            </Bubble>
          ))}
          {message.status ? (
            <MessageFooter className="gap-1">
              <Icon as={ChecksIcon} className="text-primary" />
              <Text>{message.status}</Text>
            </MessageFooter>
          ) : null}
        </MessageContent>
      </Message>
    </MessageScrollerItem>
  )
}

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

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

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

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

  return null
}

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

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

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

```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(EsuissColor.primary)
            Text(verbatim: status)
          }
        }
      }
    }
  }
}

struct MessageScrollerAttachmentView: View {
  let attachment: MessageScrollerAttachment

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

struct MessageScrollerChat: View {
  let unreadDemo: Bool

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

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

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

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

```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 com.esuiss.ui.theme.Palette
import kotlinx.coroutines.launch

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

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

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

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

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

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

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

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

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

### Load older

Loads earlier messages with `onLoadOlder` when scrolling to the top.

![Preview](https://ui.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-500">pdf</AttachmentLabel>
              </AttachmentMedia>
              <AttachmentContent>
                <AttachmentTitle>{message.attachment.name}</AttachmentTitle>
                {message.attachment.progress ? (
                  <AttachmentProgress
                    value={message.attachment.progress}
                    accessibilityLabel="Uploading"
                  />
                ) : (
                  <AttachmentDescription>
                    {message.attachment.size}
                  </AttachmentDescription>
                )}
              </AttachmentContent>
              {message.attachment.progress ? (
                <AttachmentActions>
                  <AttachmentAction accessibilityLabel="Cancel upload">
                    <Icon as={XIcon} />
                  </AttachmentAction>
                </AttachmentActions>
              ) : null}
            </Attachment>
          ) : null}
          {message.texts.map((text, index) => (
            <Bubble key={text} variant={mine ? "default" : "muted"}>
              <BubbleContent>{text}</BubbleContent>
              {message.reaction && index === message.texts.length - 1 ? (
                <BubbleReactions align={mine ? "start" : "end"}>
                  {message.reaction}
                </BubbleReactions>
              ) : null}
            </Bubble>
          ))}
          {message.status ? (
            <MessageFooter className="gap-1">
              <Icon as={ChecksIcon} className="text-primary" />
              <Text>{message.status}</Text>
            </MessageFooter>
          ) : null}
        </MessageContent>
      </Message>
    </MessageScrollerItem>
  )
}

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

export function MessageScrollerLoadOlderExample() {
  return (
    <LoadOlderDemo />
  )
}
```

```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(EsuissColor.primary)
            Text(verbatim: status)
          }
        }
      }
    }
  }
}

struct MessageScrollerAttachmentView: View {
  let attachment: MessageScrollerAttachment

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

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

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

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

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

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

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

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

```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 com.esuiss.ui.theme.Palette
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

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

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

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

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

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

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

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

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

## API Reference

### MessageScrollerProvider

Shares one scroll controller with components outside the scroller.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `React.ReactNode` | - |  |
| `scrollEdgeThreshold` | `number` | `8` | Distance in points from an edge that still counts as being at that edge. |

Also accepts the props of `MessageScrollerOptions`.

### MessageScroller

Container that clips the list and positions the button.

Also accepts the props of `ViewProps`.

### MessageScrollerViewport

Inverted `FlatList` that renders the messages.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `data` | `readonly T[]` | Required | The messages to render. |
| `keyExtractor` | `(item: T, index: number) => string` | Required | Returns a stable key for each message. |
| `renderItem` | `(info: { item: T; index: number }) => React.ReactElement \| null` | Required | Renders one message. |
| `onLoadOlder` | `() => void` | - | Called near the top so older messages can be loaded. |

Also accepts the props of `MessageScrollerViewportProps<T>`.

### MessageScrollerItem

Row wrapper with horizontal padding.

Also accepts the props of `ViewProps`.

### MessageScrollerButton

Floating button that scrolls to the newest or oldest message.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `React.ReactNode` | - |  |
| `direction` | `MessageScrollerScrollDirection` | `"end"` | `end` scrolls to the newest message, `start` to the oldest. Defaults to `end`. |
| `animated` | `boolean` | `true` | Whether the scroll is animated. Defaults to `true`. |

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

## Accessibility

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

## Notes

- The keyboard dismisses interactively on iOS and on drag on Android, and taps on handled children keep it open.
- The button uses `bg-card` with `border-border`, and the badge uses `bg-primary` with `text-primary-foreground`.
- Light and dark colors follow the system appearance.

