# Chat Support

A support landing section with a floating chat panel, quick-reply suggestions, and a launcher button.

Chat Support is an open-source block 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/blocks/chat-support

**React**

**Example**

```tsx title="components/blocks/chat-support.tsx"
"use client"

import {
  ArrowUpIcon,
  ChatCircleIcon,
  PlusIcon,
  XIcon,
} from "@phosphor-icons/react"
import * as React from "react"

import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Message, MessageContent } from "@/components/ui/message"
import {
  MessageScroller,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from "@/components/ui/message-scroller"

const image =
  "https://images.unsplash.com/photo-1634403665443-81dc4d75843a?w=900&q=80&auto=format&fit=crop"

type Line = { id: string; from: "me" | "them"; text: string }

const answers: Record<string, string> = {
  "Where's my order?":
    "Order W1048 has shipped. It arrives tomorrow between 10 AM and 2 PM.",
  "Book a repair":
    "The closest Help Desk is at suiss Union Square. Does tomorrow at 11:30 AM work?",
  "I want to return something":
    "You can return items free of charge within 14 days of delivery. Should I email you a return label?",
}

const quick = Object.keys(answers)

export function ChatSupport() {
  const [open, setOpen] = React.useState(true)
  const [lines, setLines] = React.useState<Line[]>([
    {
      id: "0",
      from: "them",
      text: "Hi Jamie, I'm the suiss Support assistant. How can I help?",
    },
  ])
  const [typing, setTyping] = React.useState(false)
  const [draft, setDraft] = React.useState("")
  const launcherRef = React.useRef<HTMLButtonElement>(null)
  const inputRef = React.useRef<HTMLInputElement>(null)
  const wasOpenRef = React.useRef(open)
  const ready = draft.trim().length > 0

  React.useEffect(() => {
    if (wasOpenRef.current === open) return
    wasOpenRef.current = open
    if (open) inputRef.current?.focus()
    else launcherRef.current?.focus()
  }, [open])

  const ask = (question: string) => {
    setLines((current) => [
      ...current,
      { id: String(current.length), from: "me", text: question },
    ])
    setTyping(true)
    window.setTimeout(() => {
      setTyping(false)
      setLines((current) => [
        ...current,
        {
          id: String(current.length),
          from: "them",
          text:
            answers[question] ??
            "Got it. I'm connecting you with a Specialist, this may take a few minutes.",
        },
      ])
    }, 1200)
  }

  return (
    <section className="relative min-h-155 overflow-hidden bg-muted">
      <div className="max-w-130 px-6 py-10 md:px-12">
        <p className="text-lg font-semibold text-muted-foreground">
          suiss Support
        </p>
        <h1 className="mt-1.5 text-4xl font-semibold tracking-tight text-balance md:text-5xl">
          How can we help you?
        </h1>
        <img
          src={image}
          alt="A phone resting on a soft beige surface"
          className="mt-6 h-55 w-full rounded-[1.125rem] bg-secondary object-cover"
        />
      </div>
      <div
        role="dialog"
        aria-label="suiss Support chat"
        inert={!open}
        data-open={open ? "" : undefined}
        className="absolute inset-x-3 bottom-3 flex h-[min(32.5rem,calc(100%-1.5rem))] origin-bottom-right scale-20 rtl:origin-bottom-left flex-col overflow-hidden rounded-[1.375rem] bg-background opacity-0 shadow-[0_0_0_0.5px_var(--color-border),0_24px_60px_rgb(0_0_0/0.2)] transition-[scale,opacity] duration-[500ms,250ms] ease-[cubic-bezier(0.3,1.15,0.5,1),ease] data-open:scale-100 data-open:opacity-100 motion-reduce:transition-none sm:inset-x-auto sm:end-6 sm:bottom-6 sm:w-90"
      >
        <div className="flex shrink-0 items-center gap-2.5 border-b border-border px-3.5 py-3">
          <span className="flex size-8.5 items-center justify-center rounded-full bg-foreground text-base font-bold text-background">
            s
          </span>
          <div className="flex flex-1 flex-col">
            <span className="text-sm font-semibold">suiss Support</span>
            <span className="flex items-center gap-1 text-xs text-[color-mix(in_oklab,var(--color-green-500),var(--foreground)_45%)] dark:text-green-500">
              <span className="size-1.5 rounded-full bg-current" />
              Online
            </span>
          </div>
          <Button
            variant="secondary"
            size="icon-sm"
            aria-label="Close chat"
            onClick={() => setOpen(false)}
            className="size-7 text-muted-foreground"
          >
            <XIcon className="size-3.25" />
          </Button>
        </div>
        <MessageScrollerProvider>
          <MessageScroller className="flex-1">
            <MessageScrollerViewport aria-label="Support conversation">
              <MessageScrollerContent className="gap-1 px-3.5 py-3">
                {lines.map((line) => (
                  <MessageScrollerItem key={line.id} messageId={line.id}>
                    <Message
                      align={line.from === "me" ? "end" : "start"}
                      className="transition-[opacity,translate] duration-700 ease-[cubic-bezier(0.32,0.72,0,1)] starting:translate-y-2 starting:opacity-0 motion-reduce:transition-none"
                    >
                      <MessageContent>
                        <Bubble
                          variant={line.from === "me" ? "default" : "muted"}
                          align={line.from === "me" ? "end" : "start"}
                          className="max-w-62.5"
                        >
                          <BubbleContent className="text-sm">
                            {line.text}
                          </BubbleContent>
                        </Bubble>
                      </MessageContent>
                    </Message>
                  </MessageScrollerItem>
                ))}
                {typing && (
                  <MessageScrollerItem messageId="typing">
                    <Message>
                      <MessageContent>
                        <Bubble variant="muted" aria-label="Typing">
                          <BubbleContent className="flex gap-1 px-3.5 py-3">
                            {[0, 1, 2].map((dot) => (
                              <span
                                key={dot}
                                style={{ animationDelay: `${dot * 180}ms` }}
                                className="size-2 animate-[pulse_1.1s_ease-in-out_infinite] rounded-full bg-muted-foreground"
                              />
                            ))}
                          </BubbleContent>
                        </Bubble>
                      </MessageContent>
                    </Message>
                  </MessageScrollerItem>
                )}
              </MessageScrollerContent>
            </MessageScrollerViewport>
          </MessageScroller>
        </MessageScrollerProvider>
        <div className="flex flex-wrap gap-1.5 px-3.5 pb-1">
          {quick
            .filter((question) => !lines.some((line) => line.text === question))
            .map((question) => (
              <Button
                key={question}
                variant="outline"
                size="sm"
                onClick={() => ask(question)}
                className="border-primary text-primary hover:text-primary"
              >
                {question}
              </Button>
            ))}
        </div>
        <form
          onSubmit={(event) => {
            event.preventDefault()
            if (!ready) return
            ask(draft.trim())
            setDraft("")
          }}
          className="flex shrink-0 items-center gap-2 px-3 pt-2 pb-3"
        >
          <Button
            type="button"
            variant="secondary"
            size="icon-sm"
            aria-label="Add attachment"
            className="text-muted-foreground"
          >
            <PlusIcon className="size-4.5" />
          </Button>
          <div className="flex min-h-8.5 flex-1 items-center rounded-full border border-input ps-3.5 pe-0.75 has-focus-visible:border-ring has-focus-visible:ring-3 has-focus-visible:ring-ring/50">
            <Input
              ref={inputRef}
              value={draft}
              onChange={(event) => setDraft(event.target.value)}
              placeholder="Type your question"
              aria-label="Type your question"
              className="h-8 flex-1 rounded-none bg-transparent px-0 focus-visible:border-transparent focus-visible:ring-0 aria-invalid:ring-0"
            />
            <Button
              type="submit"
              size="icon-xs"
              aria-label="Send"
              disabled={!ready}
              data-ready={ready ? "" : undefined}
              className="size-6.75 scale-0 transition-[scale] duration-300 ease-[cubic-bezier(0.3,1.25,0.5,1)] data-ready:scale-100 motion-reduce:transition-none"
            >
              <ArrowUpIcon className="size-4" />
            </Button>
          </div>
        </form>
      </div>
      <Button
        ref={launcherRef}
        size="icon-xl"
        aria-label="Open chat"
        aria-expanded={open}
        inert={open}
        onClick={() => setOpen(true)}
        data-open={open ? "" : undefined}
        className="absolute end-6 bottom-6 shadow-[0_10px_30px_color-mix(in_oklab,var(--color-primary)_40%,transparent)] transition-[scale] duration-400 ease-[cubic-bezier(0.3,1.25,0.5,1)] data-open:scale-0 motion-reduce:transition-none"
      >
        <ChatCircleIcon className="size-6 fill-current" />
      </Button>
    </section>
  )
}
```

A support page with a floating chat panel holding a greeting, quick-reply suggestions, the conversation and a composer. Closing the panel shrinks it into a round launcher button that reopens it. The panel spans the bottom of the screen on mobile and docks to a corner on larger screens. Use it as the in-app help widget for a store, a SaaS product or a docs site.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/chat-support
```

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 blocks chat-support
```

### Manual

1. Install the dependencies:

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

2. Add the pieces it builds on: [Bubble](https://ui.suiss.app/docs/components/bubble), [Button](https://ui.suiss.app/docs/components/button), [Input](https://ui.suiss.app/docs/components/input), [Message](https://ui.suiss.app/docs/components/message), [Message Scroller](https://ui.suiss.app/docs/components/message-scroller).

3. Copy the source into `components/blocks/chat-support.tsx`:

```tsx title="components/blocks/chat-support.tsx"
"use client"

import {
  ArrowUpIcon,
  ChatCircleIcon,
  PlusIcon,
  XIcon,
} from "@phosphor-icons/react"
import * as React from "react"

import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Message, MessageContent } from "@/components/ui/message"
import {
  MessageScroller,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from "@/components/ui/message-scroller"

const image =
  "https://images.unsplash.com/photo-1634403665443-81dc4d75843a?w=900&q=80&auto=format&fit=crop"

type Line = { id: string; from: "me" | "them"; text: string }

const answers: Record<string, string> = {
  "Where's my order?":
    "Order W1048 has shipped. It arrives tomorrow between 10 AM and 2 PM.",
  "Book a repair":
    "The closest Help Desk is at suiss Union Square. Does tomorrow at 11:30 AM work?",
  "I want to return something":
    "You can return items free of charge within 14 days of delivery. Should I email you a return label?",
}

const quick = Object.keys(answers)

export function ChatSupport() {
  const [open, setOpen] = React.useState(true)
  const [lines, setLines] = React.useState<Line[]>([
    {
      id: "0",
      from: "them",
      text: "Hi Jamie, I'm the suiss Support assistant. How can I help?",
    },
  ])
  const [typing, setTyping] = React.useState(false)
  const [draft, setDraft] = React.useState("")
  const launcherRef = React.useRef<HTMLButtonElement>(null)
  const inputRef = React.useRef<HTMLInputElement>(null)
  const wasOpenRef = React.useRef(open)
  const ready = draft.trim().length > 0

  React.useEffect(() => {
    if (wasOpenRef.current === open) return
    wasOpenRef.current = open
    if (open) inputRef.current?.focus()
    else launcherRef.current?.focus()
  }, [open])

  const ask = (question: string) => {
    setLines((current) => [
      ...current,
      { id: String(current.length), from: "me", text: question },
    ])
    setTyping(true)
    window.setTimeout(() => {
      setTyping(false)
      setLines((current) => [
        ...current,
        {
          id: String(current.length),
          from: "them",
          text:
            answers[question] ??
            "Got it. I'm connecting you with a Specialist, this may take a few minutes.",
        },
      ])
    }, 1200)
  }

  return (
    <section className="relative min-h-155 overflow-hidden bg-muted">
      <div className="max-w-130 px-6 py-10 md:px-12">
        <p className="text-lg font-semibold text-muted-foreground">
          suiss Support
        </p>
        <h1 className="mt-1.5 text-4xl font-semibold tracking-tight text-balance md:text-5xl">
          How can we help you?
        </h1>
        <img
          src={image}
          alt="A phone resting on a soft beige surface"
          className="mt-6 h-55 w-full rounded-[1.125rem] bg-secondary object-cover"
        />
      </div>
      <div
        role="dialog"
        aria-label="suiss Support chat"
        inert={!open}
        data-open={open ? "" : undefined}
        className="absolute inset-x-3 bottom-3 flex h-[min(32.5rem,calc(100%-1.5rem))] origin-bottom-right scale-20 rtl:origin-bottom-left flex-col overflow-hidden rounded-[1.375rem] bg-background opacity-0 shadow-[0_0_0_0.5px_var(--color-border),0_24px_60px_rgb(0_0_0/0.2)] transition-[scale,opacity] duration-[500ms,250ms] ease-[cubic-bezier(0.3,1.15,0.5,1),ease] data-open:scale-100 data-open:opacity-100 motion-reduce:transition-none sm:inset-x-auto sm:end-6 sm:bottom-6 sm:w-90"
      >
        <div className="flex shrink-0 items-center gap-2.5 border-b border-border px-3.5 py-3">
          <span className="flex size-8.5 items-center justify-center rounded-full bg-foreground text-base font-bold text-background">
            s
          </span>
          <div className="flex flex-1 flex-col">
            <span className="text-sm font-semibold">suiss Support</span>
            <span className="flex items-center gap-1 text-xs text-[color-mix(in_oklab,var(--color-green-500),var(--foreground)_45%)] dark:text-green-500">
              <span className="size-1.5 rounded-full bg-current" />
              Online
            </span>
          </div>
          <Button
            variant="secondary"
            size="icon-sm"
            aria-label="Close chat"
            onClick={() => setOpen(false)}
            className="size-7 text-muted-foreground"
          >
            <XIcon className="size-3.25" />
          </Button>
        </div>
        <MessageScrollerProvider>
          <MessageScroller className="flex-1">
            <MessageScrollerViewport aria-label="Support conversation">
              <MessageScrollerContent className="gap-1 px-3.5 py-3">
                {lines.map((line) => (
                  <MessageScrollerItem key={line.id} messageId={line.id}>
                    <Message
                      align={line.from === "me" ? "end" : "start"}
                      className="transition-[opacity,translate] duration-700 ease-[cubic-bezier(0.32,0.72,0,1)] starting:translate-y-2 starting:opacity-0 motion-reduce:transition-none"
                    >
                      <MessageContent>
                        <Bubble
                          variant={line.from === "me" ? "default" : "muted"}
                          align={line.from === "me" ? "end" : "start"}
                          className="max-w-62.5"
                        >
                          <BubbleContent className="text-sm">
                            {line.text}
                          </BubbleContent>
                        </Bubble>
                      </MessageContent>
                    </Message>
                  </MessageScrollerItem>
                ))}
                {typing && (
                  <MessageScrollerItem messageId="typing">
                    <Message>
                      <MessageContent>
                        <Bubble variant="muted" aria-label="Typing">
                          <BubbleContent className="flex gap-1 px-3.5 py-3">
                            {[0, 1, 2].map((dot) => (
                              <span
                                key={dot}
                                style={{ animationDelay: `${dot * 180}ms` }}
                                className="size-2 animate-[pulse_1.1s_ease-in-out_infinite] rounded-full bg-muted-foreground"
                              />
                            ))}
                          </BubbleContent>
                        </Bubble>
                      </MessageContent>
                    </Message>
                  </MessageScrollerItem>
                )}
              </MessageScrollerContent>
            </MessageScrollerViewport>
          </MessageScroller>
        </MessageScrollerProvider>
        <div className="flex flex-wrap gap-1.5 px-3.5 pb-1">
          {quick
            .filter((question) => !lines.some((line) => line.text === question))
            .map((question) => (
              <Button
                key={question}
                variant="outline"
                size="sm"
                onClick={() => ask(question)}
                className="border-primary text-primary hover:text-primary"
              >
                {question}
              </Button>
            ))}
        </div>
        <form
          onSubmit={(event) => {
            event.preventDefault()
            if (!ready) return
            ask(draft.trim())
            setDraft("")
          }}
          className="flex shrink-0 items-center gap-2 px-3 pt-2 pb-3"
        >
          <Button
            type="button"
            variant="secondary"
            size="icon-sm"
            aria-label="Add attachment"
            className="text-muted-foreground"
          >
            <PlusIcon className="size-4.5" />
          </Button>
          <div className="flex min-h-8.5 flex-1 items-center rounded-full border border-input ps-3.5 pe-0.75 has-focus-visible:border-ring has-focus-visible:ring-3 has-focus-visible:ring-ring/50">
            <Input
              ref={inputRef}
              value={draft}
              onChange={(event) => setDraft(event.target.value)}
              placeholder="Type your question"
              aria-label="Type your question"
              className="h-8 flex-1 rounded-none bg-transparent px-0 focus-visible:border-transparent focus-visible:ring-0 aria-invalid:ring-0"
            />
            <Button
              type="submit"
              size="icon-xs"
              aria-label="Send"
              disabled={!ready}
              data-ready={ready ? "" : undefined}
              className="size-6.75 scale-0 transition-[scale] duration-300 ease-[cubic-bezier(0.3,1.25,0.5,1)] data-ready:scale-100 motion-reduce:transition-none"
            >
              <ArrowUpIcon className="size-4" />
            </Button>
          </div>
        </form>
      </div>
      <Button
        ref={launcherRef}
        size="icon-xl"
        aria-label="Open chat"
        aria-expanded={open}
        inert={open}
        onClick={() => setOpen(true)}
        data-open={open ? "" : undefined}
        className="absolute end-6 bottom-6 shadow-[0_10px_30px_color-mix(in_oklab,var(--color-primary)_40%,transparent)] transition-[scale] duration-400 ease-[cubic-bezier(0.3,1.25,0.5,1)] data-open:scale-0 motion-reduce:transition-none"
      >
        <ChatCircleIcon className="size-6 fill-current" />
      </Button>
    </section>
  )
}
```

4. Update the import paths to match your project.

## Usage

```tsx
import { ChatSupport } from "@/components/blocks/chat-support"
```

```tsx
<ChatSupport />
```

Edit the `answers` record: each key becomes a quick-reply button and its value is the canned answer, while any other question gets a fallback about connecting to a Specialist. The `ask` function fakes a reply after a delay, so replace it with your support bot or live-agent API. Change the greeting and the hero photo in the `image` constant.

## Notes

Focus returns to the launcher when the panel closes. The hero photo is an Unsplash placeholder.

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/blocks-chat-support--default.light.webp)

```tsx title="uniwind/components/blocks/chat-support.tsx"
import {
  ArrowUpIcon,
  ChatCircleIcon,
  PlusIcon,
  XIcon,
} from "phosphor-react-native"
import * as React from "react"
import { I18nManager, Image, ScrollView, View } from "react-native"
import {
  cancelAnimation,
  Easing,
  FadeInDown,
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withDelay,
  withRepeat,
  withSequence,
  withTiming,
} from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { BottomBar, BottomBarContent } from "@/components/ui/bottom-bar"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
} from "@/components/ui/input-group"
import { Message, MessageContent } from "@/components/ui/message"
import {
  MessageScroller,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
  useMessageScroller,
} from "@/components/ui/message-scroller"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"

const IMAGE =
  "https://images.unsplash.com/photo-1634403665443-81dc4d75843a?w=900&q=80&auto=format&fit=crop"

type Line = { id: string; from: "me" | "them"; text: string }

type Row = { id: string; kind: "typing" } | ({ kind: "line" } & Line)

const ANSWERS: Record<string, string> = {
  "Where's my order?":
    "Order W1048 has shipped. It arrives tomorrow between 10 AM and 2 PM.",
  "Book a repair":
    "The closest Help Desk is at suiss Union Square. Does tomorrow at 11:30 AM work?",
  "I want to return something":
    "You can return items free of charge within 14 days of delivery. Should I email you a return label?",
}

const QUICK = Object.keys(ANSWERS)

const FALLBACK =
  "Got it. I'm connecting you with a Specialist, this may take a few minutes."

const PANEL_HEIGHT = 520

const PANEL_EASING = Easing.bezier(0.3, 1.15, 0.5, 1)

const LAUNCHER_EASING = Easing.bezier(0.3, 1.25, 0.5, 1)

const SEND_EASING = Easing.bezier(0.3, 1.25, 0.5, 1)

function TypingDot({ index }: { index: number }) {
  const opacity = useSharedValue(0.35)

  React.useEffect(() => {
    opacity.value = withDelay(
      index * 180,
      withRepeat(
        withSequence(
          withTiming(1, { duration: 550 }),
          withTiming(0.35, { duration: 550 })
        ),
        -1,
        false,
        undefined,
        ReduceMotion.System
      )
    )
    return () => cancelAnimation(opacity)
  }, [index, opacity])

  const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value }))

  return (
    <AnimatedView
      className="size-2 rounded-full bg-muted-foreground"
      style={animatedStyle}
    />
  )
}

function TypingDots() {
  return (
    <Message>
      <MessageContent>
        <Bubble variant="muted" accessible accessibilityLabel="Typing">
          <BubbleContent className="flex-row gap-1 px-3.5 py-3">
            {[0, 1, 2].map((dot) => (
              <TypingDot key={dot} index={dot} />
            ))}
          </BubbleContent>
        </Bubble>
      </MessageContent>
    </Message>
  )
}

function Composer({
  onSend,
  onAttach,
  onFocusChange,
}: {
  onSend: (text: string) => void
  onAttach?: () => void
  onFocusChange: (focused: boolean) => void
}) {
  const [draft, setDraft] = React.useState("")
  const { scrollToEnd } = useMessageScroller()
  const ready = draft.trim().length > 0
  const scale = useSharedValue(ready ? 1 : 0)

  React.useEffect(() => {
    scale.value = withTiming(ready ? 1 : 0, {
      duration: 300,
      easing: SEND_EASING,
      reduceMotion: ReduceMotion.System,
    })
  }, [ready, scale])

  const sendStyle = useAnimatedStyle(() => ({
    transform: [{ scale: scale.value }],
  }))

  const send = () => {
    if (!ready) return
    onSend(draft.trim())
    setDraft("")
    requestAnimationFrame(() => scrollToEnd())
  }

  return (
    <BottomBar>
      <BottomBarContent className="flex-row items-center gap-2 px-3 pt-2 pb-3">
        <Button
          variant="secondary"
          size="icon-sm"
          accessibilityLabel="Add attachment"
          onPress={onAttach}
        >
          <Icon
            as={PlusIcon}
            weight="bold"
            className="size-4.5 text-muted-foreground"
          />
        </Button>
        <InputGroup className="h-9 flex-1 rounded-full">
          <InputGroupInput
            value={draft}
            onChangeText={setDraft}
            placeholder="Type your question"
            accessibilityLabel="Type your question"
            returnKeyType="send"
            submitBehavior="submit"
            onSubmitEditing={send}
            onFocus={() => onFocusChange(true)}
            onBlur={() => onFocusChange(false)}
            className="ps-3.5"
          />
          <InputGroupAddon align="inline-end" className="pe-1">
            <AnimatedView style={sendStyle}>
              <InputGroupButton
                variant="default"
                size="icon-xs"
                accessibilityLabel="Send"
                disabled={!ready}
                onPress={send}
                className="size-6.75"
              >
                <Icon as={ArrowUpIcon} weight="bold" className="size-4" />
              </InputGroupButton>
            </AnimatedView>
          </InputGroupAddon>
        </InputGroup>
      </BottomBarContent>
    </BottomBar>
  )
}

function ChatSupport({
  onSend,
  onAttach,
  onOpenChange,
}: {
  onSend?: (text: string) => void
  onAttach?: () => void
  onOpenChange?: (open: boolean) => void
}) {
  const [open, setOpen] = React.useState(true)
  const [lines, setLines] = React.useState<Line[]>([
    {
      id: "0",
      from: "them",
      text: "Hi Jamie, I'm the suiss Support assistant. How can I help?",
    },
  ])
  const [typing, setTyping] = React.useState(false)
  const [height, setHeight] = React.useState(0)
  const [focused, setFocused] = React.useState(false)
  const timers = React.useRef<ReturnType<typeof setTimeout>[]>([])
  const panel = useSharedValue(1)
  const launcher = useSharedValue(0)

  React.useEffect(
    () => () => {
      for (const timer of timers.current) clearTimeout(timer)
    },
    []
  )

  React.useEffect(() => {
    panel.value = withTiming(open ? 1 : 0, {
      duration: open ? 500 : 250,
      easing: open ? PANEL_EASING : Easing.ease,
      reduceMotion: ReduceMotion.System,
    })
    launcher.value = withTiming(open ? 0 : 1, {
      duration: 400,
      easing: LAUNCHER_EASING,
      reduceMotion: ReduceMotion.System,
    })
  }, [open, panel, launcher])

  const panelStyle = useAnimatedStyle(() => ({
    opacity: Math.min(1, panel.value * 2),
    transform: [{ scale: 0.2 + panel.value * 0.8 }],
  }))

  const launcherStyle = useAnimatedStyle(() => ({
    transform: [{ scale: launcher.value }],
  }))

  const change = (next: boolean) => {
    setOpen(next)
    onOpenChange?.(next)
  }

  const ask = (question: string) => {
    onSend?.(question)
    setLines((current) => [
      ...current,
      { id: String(current.length), from: "me", text: question },
    ])
    setTyping(true)
    timers.current.push(
      setTimeout(() => {
        setTyping(false)
        setLines((current) => [
          ...current,
          {
            id: String(current.length),
            from: "them",
            text: ANSWERS[question] ?? FALLBACK,
          },
        ])
      }, 1200)
    )
  }

  const rows: Row[] = [
    ...lines.map((line) => ({ kind: "line" as const, ...line })),
    ...(typing ? [{ id: "typing", kind: "typing" as const }] : []),
  ]
  const quick = QUICK.filter(
    (question) => !lines.some((line) => line.text === question)
  )
  const available = Math.max(0, height - 24)
  const panelHeight = focused ? available : Math.min(PANEL_HEIGHT, available)

  return (
    <Screen variant="secondary">
      <View
        className="flex-1"
        onLayout={(event) => setHeight(event.nativeEvent.layout.height)}
      >
        <ScrollView
          accessibilityElementsHidden={open}
          importantForAccessibility={open ? "no-hide-descendants" : "auto"}
          contentContainerClassName="px-6 py-10"
        >
          <Text className="text-lg font-semibold text-muted-foreground">
            suiss Support
          </Text>
          <Text
            role="heading"
            className="mt-1.5 text-3xl font-semibold tracking-tight"
          >
            How can we help you?
          </Text>
          <Image
            source={{ uri: IMAGE }}
            resizeMode="cover"
            accessible
            accessibilityLabel="A phone resting on a soft beige surface"
            accessibilityIgnoresInvertColors
            className="mt-6 h-55 w-full rounded-[1.125rem] bg-secondary"
          />
        </ScrollView>
        {height > 0 ? (
          <AnimatedView
            pointerEvents={open ? "auto" : "none"}
            accessibilityViewIsModal={open}
            accessibilityElementsHidden={!open}
            importantForAccessibility={open ? "auto" : "no-hide-descendants"}
            accessibilityLabel="suiss Support chat"
            className="absolute inset-x-3 bottom-3 overflow-hidden rounded-[1.375rem] border border-border bg-background shadow-lg"
            style={[
              {
                height: panelHeight,
                transformOrigin: I18nManager.isRTL
                  ? "left bottom"
                  : "right bottom",
              },
              panelStyle,
            ]}
          >
            <MessageScrollerProvider>
              <View className="flex-row items-center gap-2.5 border-b border-border px-3.5 py-3">
                <View className="size-8.5 items-center justify-center rounded-full bg-foreground">
                  <Text className="text-base font-bold text-background">s</Text>
                </View>
                <View className="flex-1">
                  <Text role="heading" className="text-sm font-semibold">
                    suiss Support
                  </Text>
                  <View className="flex-row items-center gap-1">
                    <View className="size-1.5 rounded-full bg-green-500" />
                    <Text className="text-xs text-green-500">Online</Text>
                  </View>
                </View>
                <Button
                  variant="secondary"
                  size="icon-sm"
                  accessibilityLabel="Close chat"
                  onPress={() => change(false)}
                  className="size-7"
                >
                  <Icon
                    as={XIcon}
                    weight="bold"
                    className="size-3.25 text-muted-foreground"
                  />
                </Button>
              </View>
              <MessageScroller>
                <MessageScrollerViewport
                  data={rows}
                  keyExtractor={(row) => row.id}
                  accessibilityLabel="Support conversation"
                  contentContainerClassName="gap-1 px-1 py-3"
                  renderItem={({ item }) => (
                    <MessageScrollerItem>
                      {item.kind === "typing" ? (
                        <TypingDots />
                      ) : (
                        <AnimatedView
                          entering={FadeInDown.duration(700)
                            .easing(Easing.bezier(0.32, 0.72, 0, 1))
                            .reduceMotion(ReduceMotion.System)}
                        >
                          <Message align={item.from === "me" ? "end" : "start"}>
                            <MessageContent>
                              <Bubble
                                variant={
                                  item.from === "me" ? "default" : "muted"
                                }
                                align={item.from === "me" ? "end" : "start"}
                              >
                                <BubbleContent>
                                  <Text className="text-sm">{item.text}</Text>
                                </BubbleContent>
                              </Bubble>
                            </MessageContent>
                          </Message>
                        </AnimatedView>
                      )}
                    </MessageScrollerItem>
                  )}
                />
              </MessageScroller>
              {quick.length > 0 ? (
                <ScrollView
                  horizontal
                  showsHorizontalScrollIndicator={false}
                  keyboardShouldPersistTaps="handled"
                  className="grow-0"
                  contentContainerClassName="gap-1.5 px-3.5 pb-1"
                >
                  {quick.map((question) => (
                    <Button
                      key={question}
                      variant="outline"
                      size="sm"
                      onPress={() => ask(question)}
                      className="border-primary"
                    >
                      <Text className="text-primary">{question}</Text>
                    </Button>
                  ))}
                </ScrollView>
              ) : null}
              <Composer
                onSend={ask}
                onAttach={onAttach}
                onFocusChange={setFocused}
              />
            </MessageScrollerProvider>
          </AnimatedView>
        ) : null}
        <AnimatedView
          pointerEvents={open ? "none" : "auto"}
          accessibilityElementsHidden={open}
          importantForAccessibility={open ? "no-hide-descendants" : "auto"}
          className="absolute end-6 bottom-6"
          style={launcherStyle}
        >
          <Button
            size="icon-xl"
            accessibilityLabel="Open chat"
            accessibilityState={{ expanded: open }}
            onPress={() => change(true)}
            className="rounded-full shadow-lg"
          >
            <Icon as={ChatCircleIcon} weight="fill" className="size-6" />
          </Button>
        </AnimatedView>
      </View>
    </Screen>
  )
}

export { ChatSupport }
```

```swift title="swift/Blocks/ChatSupport.swift"
import PhosphorSwift
import SwiftUI

public struct ChatSupport: View {
  struct Line: Identifiable, Equatable {
    let id: String
    let fromMe: Bool
    let text: String
  }

  enum Entry: Identifiable {
    case line(Line)
    case typing

    var id: String {
      switch self {
      case .line(let line): line.id
      case .typing: "typing"
      }
    }
  }

  static let image = URL(string: "https://images.unsplash.com/photo-1634403665443-81dc4d75843a?w=900&q=80&auto=format&fit=crop")

  static let quick = [
    "Where's my order?",
    "Book a repair",
    "I want to return something",
  ]

  static let answers = [
    "Where's my order?": "Order W1048 has shipped. It arrives tomorrow between 10 AM and 2 PM.",
    "Book a repair": "The closest Help Desk is at suiss Union Square. Does tomorrow at 11:30 AM work?",
    "I want to return something": "You can return items free of charge within 14 days of delivery. Should I email you a return label?",
  ]

  static let fallback = "Got it. I'm connecting you with a Specialist, this may take a few minutes."

  static var greeting: Line {
    Line(id: "0", fromMe: false, text: "Hi Jamie, I'm the suiss Support assistant. How can I help?")
  }

  private var onSend: ((String) -> Void)?
  private var onAttach: (() -> Void)?
  private var onOpenChange: ((Bool) -> Void)?
  @State private var open: Bool
  @State private var lines: [Line]
  @State private var typing: Bool
  @State private var draft: String
  @State private var replyTasks: [Task<Void, Never>] = []
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init(
    onSend: ((_ text: String) -> Void)? = nil,
    onAttach: (() -> Void)? = nil,
    onOpenChange: ((_ open: Bool) -> Void)? = nil
  ) {
    self.init(open: true)
    self.onSend = onSend
    self.onAttach = onAttach
    self.onOpenChange = onOpenChange
  }

  init(open: Bool, asked: [String] = [], typing: Bool = false, draft: String = "") {
    var lines = [Self.greeting]
    for (index, question) in asked.enumerated() {
      lines.append(Line(id: "\(lines.count)", fromMe: true, text: question))
      if !typing || index < asked.count - 1 {
        lines.append(Line(id: "\(lines.count)", fromMe: false, text: Self.answers[question] ?? Self.fallback))
      }
    }
    _open = State(initialValue: open)
    _lines = State(initialValue: lines)
    _typing = State(initialValue: typing)
    _draft = State(initialValue: draft)
  }

  public var body: some View {
    Screen(background: .muted) {
      ZStack(alignment: .bottomTrailing) {
        KeyboardScrollView {
          hero
        }
        .accessibilityHidden(open)
        if open {
          panel
            .padding(12)
            .transition(
              reduceMotion
                ? .opacity
                : .scale(scale: 0.2, anchor: .bottomTrailing).combined(with: .opacity)
            )
            .zIndex(1)
        } else {
          Fab(accessibilityLabel: "Open chat", size: .lg) {
            open = true
          } icon: {
            Ph.chatCircle.fill
          }
          .padding(8)
          .transition(reduceMotion ? .opacity : .scale)
        }
      }
      .animation(reduceMotion ? nil : .spring(response: 0.45, dampingFraction: 0.72), value: open)
      .onChange(of: open) { _, value in onOpenChange?(value) }
    }
    .onDisappear {
      replyTasks.forEach { $0.cancel() }
      replyTasks.removeAll()
    }
  }

  private var hero: some View {
    VStack(alignment: .leading, spacing: 0) {
      Text(verbatim: "suiss Support")
        .scaledFont(.lg, weight: .semibold)
        .foregroundStyle(EsuissColor.mutedForeground)
      Text("How can we help you?")
        .scaledTextStyle(.xl3, weight: .semibold)
        .padding(.top, 6)
        .accessibilityAddTraits(.isHeader)
      EsuissColor.secondary
        .overlay {
          AsyncImage(url: Self.image) { phase in
            if let image = phase.image {
              image.resizable().scaledToFill()
            }
          }
        }
        .frame(height: 220)
        .clipShape(RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous))
        .padding(.top, 24)
        .accessibilityLabel(Text("A phone resting on a soft beige surface"))
    }
    .frame(maxWidth: .infinity, alignment: .leading)
    .padding(.horizontal, 24)
    .padding(.vertical, 40)
  }

  private var panel: some View {
    var entries = lines.map(Entry.line)
    if typing { entries.append(.typing) }
    let ready = !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
    let remaining = Self.quick.filter { question in !lines.contains { $0.text == question } }
    let shape = RoundedRectangle(cornerRadius: 22, style: .continuous)
    return VStack(spacing: 0) {
      HStack(spacing: 10) {
        Text(verbatim: "s")
          .font(.system(size: TextStyle.base.size, weight: .bold))
          .foregroundStyle(EsuissColor.background)
          .frame(width: 34, height: 34)
          .background(EsuissColor.foreground, in: Circle())
          .accessibilityHidden(true)
        VStack(alignment: .leading, spacing: 0) {
          Text(verbatim: "suiss Support")
            .scaledFont(.sm, weight: .semibold)
          HStack(spacing: 4) {
            Circle().frame(width: 6, height: 6)
            Text("Online")
          }
          .scaledFont(.xs)
          .foregroundStyle(Color.adaptive(light: Palette.green600, dark: Palette.green400))
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        .accessibilityElement(children: .combine)
        Button {
          open = false
        } label: {
          Ph.x.bold.frame(width: 13, height: 13)
        }
        .buttonStyle(.variant(.secondary, size: .iconSm))
        .accessibilityLabel(Text("Close chat"))
      }
      .padding(.horizontal, 14)
      .padding(.vertical, 12)
      .overlay(alignment: .bottom) {
        EsuissColor.border.frame(height: 1)
      }
      MessageScroller(entries, spacing: 4) { entry in
        switch entry {
        case .line(let line):
          Message(align: line.fromMe ? .end : .start) {
            MessageContent {
              Bubble(variant: line.fromMe ? .default : .muted, align: line.fromMe ? .end : .start) {
                BubbleContent(verbatim: line.text)
              }
            }
          }
          .transition(.opacity.combined(with: .offset(y: 8)))
        case .typing:
          ChatSupportTyping()
        }
      }
      .accessibilityLabel(Text("Support conversation"))
      .frame(maxHeight: .infinity)
      if !remaining.isEmpty {
        ScrollView(.horizontal) {
          HStack(spacing: 6) {
            ForEach(remaining, id: \.self) { question in
              Button(LocalizedStringKey(question)) {
                ask(question)
              }
              .buttonStyle(.variant(.tinted, size: .sm))
            }
          }
          .padding(.horizontal, 14)
        }
        .scrollIndicators(.hidden)
        .padding(.bottom, 4)
      }
      HStack(spacing: 8) {
        Button {
          onAttach?()
        } label: {
          Ph.plus.bold.frame(width: 18, height: 18)
        }
        .buttonStyle(.variant(.secondary, size: .iconSm))
        .accessibilityLabel(Text("Add attachment"))
        InputGroup {
          InputGroupInput("Type your question", text: $draft)
            .submitLabel(.send)
            .onSubmit(submit)
          InputGroupAddon(align: .inlineEnd) {
            InputGroupButton(variant: .default, size: .iconXs, action: submit) {
              Ph.arrowUp.bold.frame(width: 14, height: 14)
            }
            .scaleEffect(ready ? 1 : 0)
            .disabled(!ready)
            .accessibilityLabel(Text("Send"))
            .animation(reduceMotion ? nil : .spring(response: 0.3, dampingFraction: 0.6), value: ready)
          }
        }
      }
      .padding(.horizontal, 12)
      .padding(.top, 8)
      .padding(.bottom, 12)
    }
    .environment(\.screenBackground, EsuissColor.background)
    .frame(maxWidth: .infinity, maxHeight: 520)
    .background(EsuissColor.background, in: shape)
    .clipShape(shape)
    .overlay {
      shape.strokeBorder(EsuissColor.border, lineWidth: 0.5)
    }
    .shadow(Shadow.lg)
    .accessibilityElement(children: .contain)
    .accessibilityLabel(Text("suiss Support chat"))
    .accessibilityAddTraits(.isModal)
  }

  private func submit() {
    let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
    guard !text.isEmpty else { return }
    draft = ""
    ask(text)
  }

  private func ask(_ question: String) {
    onSend?(question)
    withAnimation(reduceMotion ? nil : .timingCurve(0.32, 0.72, 0, 1, duration: 0.7)) {
      lines.append(Line(id: String(lines.count), fromMe: true, text: question))
    }
    typing = true
    replyTasks.append(Task {
      guard (try? await Task.sleep(for: .milliseconds(1200))) != nil else { return }
      typing = false
      withAnimation(reduceMotion ? nil : .timingCurve(0.32, 0.72, 0, 1, duration: 0.7)) {
        lines.append(Line(id: String(lines.count), fromMe: false, text: Self.answers[question] ?? Self.fallback))
      }
    })
  }
}

private struct ChatSupportTyping: View {
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  var body: some View {
    Message {
      MessageContent {
        Bubble(variant: .muted) {
          BubbleContent {
            TimelineView(.animation(paused: reduceMotion)) { context in
              let time = context.date.timeIntervalSinceReferenceDate
              HStack(spacing: 4) {
                ForEach(0..<3, id: \.self) { dot in
                  Circle()
                    .fill(EsuissColor.mutedForeground)
                    .frame(width: 8, height: 8)
                    .opacity(reduceMotion ? 1 : 0.55 + 0.45 * abs(sin((time - Double(dot) * 0.18) * .pi / 1.1)))
                }
              }
              .padding(.vertical, 6)
            }
          }
        }
      }
    }
    .accessibilityElement(children: .ignore)
    .accessibilityLabel(Text("Typing"))
  }
}
```

```kt title="kotlin/blocks/ChatSupport.kt"
package com.esuiss.ui.blocks

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.paneTitle
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.min
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Bold
import com.adamglin.phosphoricons.Fill
import com.adamglin.phosphoricons.Light
import com.adamglin.phosphoricons.bold.ArrowUp
import com.adamglin.phosphoricons.bold.Plus
import com.adamglin.phosphoricons.bold.X
import com.adamglin.phosphoricons.fill.ChatCircle
import com.adamglin.phosphoricons.light.DeviceMobile
import com.esuiss.ui.components.Bubble
import com.esuiss.ui.components.BubbleAlign
import com.esuiss.ui.components.BubbleContent
import com.esuiss.ui.components.BubbleVariant
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonSize
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.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.LocalContentStyle
import com.esuiss.ui.components.Message
import com.esuiss.ui.components.MessageAlign
import com.esuiss.ui.components.MessageContent
import com.esuiss.ui.components.MessageScroller
import com.esuiss.ui.components.Screen
import com.esuiss.ui.components.ScreenVariant
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Shadows
import com.esuiss.ui.theme.Typography
import com.esuiss.ui.theme.shadow
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

private val SupportPopEasing = CubicBezierEasing(0.3f, 1.25f, 0.5f, 1f)

private val SupportOpenEasing = CubicBezierEasing(0.3f, 1.15f, 0.5f, 1f)

private val SupportEnterEasing = CubicBezierEasing(0.32f, 0.72f, 0f, 1f)

private class SupportLine(val id: String, val mine: Boolean, val text: String)

private val SupportAnswers = linkedMapOf(
    "Where's my order?" to "Order W1048 has shipped. It arrives tomorrow between 10 AM and 2 PM.",
    "Book a repair" to "The closest Help Desk is at suiss Union Square. Does tomorrow at 11:30 AM work?",
    "I want to return something" to "You can return items free of charge within 14 days of delivery. Should I email you a return label?",
)

private const val SupportFallback = "Got it. I'm connecting you with a Specialist, this may take a few minutes."

private const val SupportTypingKey = "typing"

@Composable
fun ChatSupport(
    modifier: Modifier = Modifier,
    onSend: (text: String) -> Unit = {},
    onAttach: () -> Unit = {},
    onOpenChange: (open: Boolean) -> Unit = {},
) {
    val colors = EsuissTheme.colors
    var open by rememberSaveable { mutableStateOf(true) }
    val lines = remember {
        mutableStateListOf(SupportLine("0", false, "Hi Jamie, I'm the suiss Support assistant. How can I help?"))
    }
    var typing by remember { mutableStateOf(false) }
    val scope = rememberCoroutineScope()
    val progress by animateFloatAsState(
        if (open) 1f else 0f,
        if (open) tween(500, easing = SupportOpenEasing) else tween(250),
        label = "panel",
    )
    val launcher by animateFloatAsState(if (open) 0f else 1f, tween(400, easing = SupportPopEasing), label = "launcher")

    fun ask(question: String) {
        onSend(question)
        lines += SupportLine(lines.size.toString(), true, question)
        typing = true
        scope.launch {
            delay(1200)
            typing = false
            lines += SupportLine(lines.size.toString(), false, SupportAnswers[question] ?: SupportFallback)
        }
    }

    Screen(modifier, variant = ScreenVariant.Secondary) {
        Column(
            Modifier
                .fillMaxSize()
                .verticalScroll(rememberScrollState())
                .padding(horizontal = 24.dp, vertical = 32.dp),
        ) {
            BasicText("suiss Support", style = Typography.lg.copy(color = colors.mutedForeground, fontWeight = FontWeight.SemiBold))
            BasicText(
                "How can we help you?",
                modifier = Modifier
                    .padding(top = 6.dp)
                    .semantics { heading() },
                style = Typography.xl3.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold),
            )
            Box(
                Modifier
                    .padding(top = 24.dp)
                    .fillMaxWidth()
                    .height(220.dp)
                    .background(colors.secondary, RoundedCornerShape(18.dp))
                    .clearAndSetSemantics { contentDescription = "A phone resting on a soft beige surface" },
                contentAlignment = Alignment.Center,
            ) {
                Icon(PhosphorIcons.Light.DeviceMobile, contentDescription = null, size = 96.dp, tint = colors.mutedForeground)
            }
        }
        BoxWithConstraints(
            Modifier
                .fillMaxSize()
                .imePadding()
                .padding(12.dp),
            contentAlignment = Alignment.BottomCenter,
        ) {
            if (progress > 0f) {
                val shape = RoundedCornerShape(22.dp)
                Column(
                    modifier = Modifier
                        .fillMaxWidth()
                        .height(min(520.dp, maxHeight))
                        .graphicsLayer {
                            transformOrigin = TransformOrigin(1f, 1f)
                            val scale = 0.2f + 0.8f * progress
                            scaleX = scale
                            scaleY = scale
                            alpha = progress.coerceIn(0f, 1f)
                        }
                        .shadow(Shadows.lg, shape)
                        .clip(shape)
                        .background(colors.background, shape)
                        .semantics { paneTitle = "suiss Support chat" },
                ) {
                    SupportPanel(lines, typing, ::ask, onAttach) {
                        open = false
                        onOpenChange(false)
                    }
                }
            }
            if (launcher > 0f) {
                Box(
                    Modifier
                        .align(Alignment.BottomEnd)
                        .padding(12.dp)
                        .graphicsLayer {
                            scaleX = launcher
                            scaleY = launcher
                        },
                ) {
                    Button(
                        onClick = {
                            open = true
                            onOpenChange(true)
                        },
                        size = ButtonSize.IconXl, enabled = !open) {
                        Icon(PhosphorIcons.Fill.ChatCircle, contentDescription = "Open chat", size = 24.dp)
                    }
                }
            }
        }
    }
}

@Composable
private fun ColumnScope.SupportPanel(
    lines: List<SupportLine>,
    typing: Boolean,
    onAsk: (String) -> Unit,
    onAttach: () -> Unit,
    onClose: () -> Unit,
) {
    val colors = EsuissTheme.colors
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(horizontal = 14.dp, vertical = 12.dp),
        horizontalArrangement = Arrangement.spacedBy(10.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Box(
            Modifier
                .size(34.dp)
                .background(colors.foreground, CircleShape),
            contentAlignment = Alignment.Center,
        ) {
            BasicText("s", style = Typography.base.copy(color = colors.background, fontWeight = FontWeight.Bold))
        }
        Column(Modifier.weight(1f)) {
            BasicText("suiss Support", style = Typography.sm.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold))
            Row(horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) {
                Box(
                    Modifier
                        .size(6.dp)
                        .background(Palette.green500, CircleShape),
                )
                BasicText("Online", style = Typography.xs.copy(color = if (colors.isDark) Palette.green400 else Palette.green600))
            }
        }
        Button(onClick = onClose, variant = ButtonVariant.Secondary, size = ButtonSize.IconSm) {
            Icon(PhosphorIcons.Bold.X, contentDescription = "Close chat", size = 13.dp, tint = colors.mutedForeground)
        }
    }
    Box(
        Modifier
            .fillMaxWidth()
            .height(1.dp)
            .background(colors.border),
    )
    val keys = buildList {
        lines.forEach { add(it.id) }
        if (typing) add(SupportTypingKey)
    }
    MessageScroller(
        items = keys,
        key = { it },
        modifier = Modifier
            .weight(1f)
            .fillMaxWidth(),
        contentPadding = PaddingValues(horizontal = 14.dp, vertical = 12.dp),
        verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.Bottom),
    ) { key ->
        val line = lines.firstOrNull { it.id == key }
        if (line == null) SupportTypingBubble() else SupportBubble(line)
    }
    val quick = SupportAnswers.keys.filter { question -> lines.none { it.text == question } }
    if (quick.isNotEmpty()) {
        FlowRow(
            modifier = Modifier
                .fillMaxWidth()
                .padding(start = 14.dp, end = 14.dp, bottom = 4.dp),
            horizontalArrangement = Arrangement.spacedBy(6.dp),
            verticalArrangement = Arrangement.spacedBy(6.dp),
        ) {
            quick.forEach { question ->
                Button(question, { onAsk(question) }, variant = ButtonVariant.Outline, size = ButtonSize.Sm)
            }
        }
    }
    SupportComposer(onAsk, onAttach)
}

@Composable
private fun SupportBubble(line: SupportLine) {
    val appear = remember { Animatable(0f) }
    LaunchedEffect(Unit) { appear.animateTo(1f, tween(700, easing = SupportEnterEasing)) }
    Message(
        modifier = Modifier.graphicsLayer {
            alpha = appear.value
            translationY = (1f - appear.value) * 8.dp.toPx()
        },
        align = if (line.mine) MessageAlign.End else MessageAlign.Start,
    ) {
        MessageContent {
            Bubble(
                modifier = Modifier.widthIn(max = 250.dp),
                variant = if (line.mine) BubbleVariant.Default else BubbleVariant.Muted,
                align = if (line.mine) BubbleAlign.End else BubbleAlign.Start,
            ) {
                BubbleContent {
                    BasicText(line.text, style = LocalContentStyle.current.merge(Typography.sm))
                }
            }
        }
    }
}

@Composable
private fun SupportTypingBubble() {
    val colors = EsuissTheme.colors
    val transition = rememberInfiniteTransition(label = "typing")
    Message {
        MessageContent {
            Bubble(variant = BubbleVariant.Muted) {
                BubbleContent(Modifier.semantics { contentDescription = "Typing" }) {
                    Row(
                        modifier = Modifier.padding(vertical = 5.dp),
                        horizontalArrangement = Arrangement.spacedBy(4.dp),
                    ) {
                        repeat(3) { dot ->
                            val alpha by transition.animateFloat(
                                initialValue = 0.35f,
                                targetValue = 1f,
                                animationSpec = infiniteRepeatable(
                                    animation = tween(550, delayMillis = dot * 180),
                                    repeatMode = RepeatMode.Reverse,
                                ),
                                label = "dot",
                            )
                            Box(
                                Modifier
                                    .size(8.dp)
                                    .graphicsLayer { this.alpha = alpha }
                                    .background(colors.mutedForeground, CircleShape),
                            )
                        }
                    }
                }
            }
        }
    }
}

@Composable
private fun SupportComposer(onSend: (String) -> Unit, onAttach: () -> Unit) {
    val colors = EsuissTheme.colors
    var draft by rememberSaveable { mutableStateOf("") }
    val ready = draft.isNotBlank()
    val scale by animateFloatAsState(if (ready) 1f else 0f, tween(300, easing = SupportPopEasing), label = "send")

    fun submit() {
        if (!ready) return
        onSend(draft.trim())
        draft = ""
    }

    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(start = 12.dp, end = 12.dp, top = 8.dp, bottom = 12.dp),
        horizontalArrangement = Arrangement.spacedBy(8.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Button(onClick = onAttach, variant = ButtonVariant.Secondary, size = ButtonSize.IconSm) {
            Icon(PhosphorIcons.Bold.Plus, contentDescription = "Add attachment", size = 18.dp, tint = colors.mutedForeground)
        }
        InputGroup(Modifier.weight(1f)) {
            InputGroupInput(
                value = draft,
                onValueChange = { draft = it },
                placeholder = "Type your question",
                keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences, imeAction = ImeAction.Send),
                keyboardActions = KeyboardActions(onSend = { submit() }),
            )
            InputGroupAddon(align = InputGroupAddonAlign.InlineEnd) {
                InputGroupButton(
                    onClick = ::submit,
                    modifier = Modifier.graphicsLayer {
                        scaleX = scale
                        scaleY = scale
                    },
                    size = InputGroupButtonSize.IconXs,
                    variant = ButtonVariant.Default,
                    enabled = ready,
                ) {
                    Icon(PhosphorIcons.Bold.ArrowUp, contentDescription = "Send", size = 14.dp)
                }
            }
        }
    }
}
```

Composes `Screen`, `Message`, `Bubble`, `MessageScroller`, `InputGroup`, `Button`, `Icon` and `BottomBar`. The chat panel opens and closes with a scale animation from a launcher button, quick questions disappear once asked, and each question gets a typed answer after a short typing indicator. The panel grows to the available height while the composer has focus.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/chat-support
```

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 chat-support
```

**SwiftUI**

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

**Jetpack Compose**

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

The CLI also adds what it builds on: [Bottom Bar](https://ui.suiss.app/docs/components/bottom-bar), [Bubble](https://ui.suiss.app/docs/components/bubble), [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Input Group](https://ui.suiss.app/docs/components/input-group), [Message](https://ui.suiss.app/docs/components/message), [Message Scroller](https://ui.suiss.app/docs/components/message-scroller), [Screen](https://ui.suiss.app/docs/components/screen), [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 phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Bottom Bar](https://ui.suiss.app/docs/components/bottom-bar), [Bubble](https://ui.suiss.app/docs/components/bubble), [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Input Group](https://ui.suiss.app/docs/components/input-group), [Message](https://ui.suiss.app/docs/components/message), [Message Scroller](https://ui.suiss.app/docs/components/message-scroller), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text), 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/blocks/chat-support.tsx`:

- `uniwind/components/blocks/chat-support.tsx`: shown above

5. Update the import paths to match your project.

**NativeWind**

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: [Bottom Bar](https://ui.suiss.app/docs/components/bottom-bar), [Bubble](https://ui.suiss.app/docs/components/bubble), [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Input Group](https://ui.suiss.app/docs/components/input-group), [Message](https://ui.suiss.app/docs/components/message), [Message Scroller](https://ui.suiss.app/docs/components/message-scroller), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text), 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/blocks/chat-support.tsx`:

```tsx title="nativewind/components/blocks/chat-support.tsx"
import {
  ArrowUpIcon,
  ChatCircleIcon,
  PlusIcon,
  XIcon,
} from "phosphor-react-native"
import * as React from "react"
import { I18nManager, Image, ScrollView, View } from "react-native"
import {
  cancelAnimation,
  Easing,
  FadeInDown,
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withDelay,
  withRepeat,
  withSequence,
  withTiming,
} from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { BottomBar, BottomBarContent } from "@/components/ui/bottom-bar"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
} from "@/components/ui/input-group"
import { Message, MessageContent } from "@/components/ui/message"
import {
  MessageScroller,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
  useMessageScroller,
} from "@/components/ui/message-scroller"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"

const IMAGE =
  "https://images.unsplash.com/photo-1634403665443-81dc4d75843a?w=900&q=80&auto=format&fit=crop"

type Line = { id: string; from: "me" | "them"; text: string }

type Row = { id: string; kind: "typing" } | ({ kind: "line" } & Line)

const ANSWERS: Record<string, string> = {
  "Where's my order?":
    "Order W1048 has shipped. It arrives tomorrow between 10 AM and 2 PM.",
  "Book a repair":
    "The closest Help Desk is at suiss Union Square. Does tomorrow at 11:30 AM work?",
  "I want to return something":
    "You can return items free of charge within 14 days of delivery. Should I email you a return label?",
}

const QUICK = Object.keys(ANSWERS)

const FALLBACK =
  "Got it. I'm connecting you with a Specialist, this may take a few minutes."

const PANEL_HEIGHT = 520

const PANEL_EASING = Easing.bezier(0.3, 1.15, 0.5, 1)

const LAUNCHER_EASING = Easing.bezier(0.3, 1.25, 0.5, 1)

const SEND_EASING = Easing.bezier(0.3, 1.25, 0.5, 1)

function TypingDot({ index }: { index: number }) {
  const opacity = useSharedValue(0.35)

  React.useEffect(() => {
    opacity.value = withDelay(
      index * 180,
      withRepeat(
        withSequence(
          withTiming(1, { duration: 550 }),
          withTiming(0.35, { duration: 550 })
        ),
        -1,
        false,
        undefined,
        ReduceMotion.System
      )
    )
    return () => cancelAnimation(opacity)
  }, [index, opacity])

  const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value }))

  return (
    <AnimatedView
      className="size-2 rounded-full bg-muted-foreground"
      style={animatedStyle}
    />
  )
}

function TypingDots() {
  return (
    <Message>
      <MessageContent>
        <Bubble variant="muted" accessible accessibilityLabel="Typing">
          <BubbleContent className="flex-row gap-1 px-3.5 py-3">
            {[0, 1, 2].map((dot) => (
              <TypingDot key={dot} index={dot} />
            ))}
          </BubbleContent>
        </Bubble>
      </MessageContent>
    </Message>
  )
}

function Composer({
  onSend,
  onAttach,
  onFocusChange,
}: {
  onSend: (text: string) => void
  onAttach?: () => void
  onFocusChange: (focused: boolean) => void
}) {
  const [draft, setDraft] = React.useState("")
  const { scrollToEnd } = useMessageScroller()
  const ready = draft.trim().length > 0
  const scale = useSharedValue(ready ? 1 : 0)

  React.useEffect(() => {
    scale.value = withTiming(ready ? 1 : 0, {
      duration: 300,
      easing: SEND_EASING,
      reduceMotion: ReduceMotion.System,
    })
  }, [ready, scale])

  const sendStyle = useAnimatedStyle(() => ({
    transform: [{ scale: scale.value }],
  }))

  const send = () => {
    if (!ready) return
    onSend(draft.trim())
    setDraft("")
    requestAnimationFrame(() => scrollToEnd())
  }

  return (
    <BottomBar>
      <BottomBarContent className="flex-row items-center gap-2 px-3 pt-2 pb-3">
        <Button
          variant="secondary"
          size="icon-sm"
          accessibilityLabel="Add attachment"
          onPress={onAttach}
        >
          <Icon
            as={PlusIcon}
            weight="bold"
            className="size-[1.125rem] text-muted-foreground"
          />
        </Button>
        <InputGroup className="h-9 flex-1 rounded-full">
          <InputGroupInput
            value={draft}
            onChangeText={setDraft}
            placeholder="Type your question"
            accessibilityLabel="Type your question"
            returnKeyType="send"
            submitBehavior="submit"
            onSubmitEditing={send}
            onFocus={() => onFocusChange(true)}
            onBlur={() => onFocusChange(false)}
            className="ps-3.5"
          />
          <InputGroupAddon align="inline-end" className="pe-1">
            <AnimatedView style={sendStyle}>
              <InputGroupButton
                variant="default"
                size="icon-xs"
                accessibilityLabel="Send"
                disabled={!ready}
                onPress={send}
                className="size-[1.6875rem]"
              >
                <Icon as={ArrowUpIcon} weight="bold" className="size-4" />
              </InputGroupButton>
            </AnimatedView>
          </InputGroupAddon>
        </InputGroup>
      </BottomBarContent>
    </BottomBar>
  )
}

function ChatSupport({
  onSend,
  onAttach,
  onOpenChange,
}: {
  onSend?: (text: string) => void
  onAttach?: () => void
  onOpenChange?: (open: boolean) => void
}) {
  const [open, setOpen] = React.useState(true)
  const [lines, setLines] = React.useState<Line[]>([
    {
      id: "0",
      from: "them",
      text: "Hi Jamie, I'm the suiss Support assistant. How can I help?",
    },
  ])
  const [typing, setTyping] = React.useState(false)
  const [height, setHeight] = React.useState(0)
  const [focused, setFocused] = React.useState(false)
  const timers = React.useRef<ReturnType<typeof setTimeout>[]>([])
  const panel = useSharedValue(1)
  const launcher = useSharedValue(0)

  React.useEffect(
    () => () => {
      for (const timer of timers.current) clearTimeout(timer)
    },
    []
  )

  React.useEffect(() => {
    panel.value = withTiming(open ? 1 : 0, {
      duration: open ? 500 : 250,
      easing: open ? PANEL_EASING : Easing.ease,
      reduceMotion: ReduceMotion.System,
    })
    launcher.value = withTiming(open ? 0 : 1, {
      duration: 400,
      easing: LAUNCHER_EASING,
      reduceMotion: ReduceMotion.System,
    })
  }, [open, panel, launcher])

  const panelStyle = useAnimatedStyle(() => ({
    opacity: Math.min(1, panel.value * 2),
    transform: [{ scale: 0.2 + panel.value * 0.8 }],
  }))

  const launcherStyle = useAnimatedStyle(() => ({
    transform: [{ scale: launcher.value }],
  }))

  const change = (next: boolean) => {
    setOpen(next)
    onOpenChange?.(next)
  }

  const ask = (question: string) => {
    onSend?.(question)
    setLines((current) => [
      ...current,
      { id: String(current.length), from: "me", text: question },
    ])
    setTyping(true)
    timers.current.push(
      setTimeout(() => {
        setTyping(false)
        setLines((current) => [
          ...current,
          {
            id: String(current.length),
            from: "them",
            text: ANSWERS[question] ?? FALLBACK,
          },
        ])
      }, 1200)
    )
  }

  const rows: Row[] = [
    ...lines.map((line) => ({ kind: "line" as const, ...line })),
    ...(typing ? [{ id: "typing", kind: "typing" as const }] : []),
  ]
  const quick = QUICK.filter(
    (question) => !lines.some((line) => line.text === question)
  )
  const available = Math.max(0, height - 24)
  const panelHeight = focused ? available : Math.min(PANEL_HEIGHT, available)

  return (
    <Screen variant="secondary">
      <View
        className="flex-1"
        onLayout={(event) => setHeight(event.nativeEvent.layout.height)}
      >
        <ScrollView
          accessibilityElementsHidden={open}
          importantForAccessibility={open ? "no-hide-descendants" : "auto"}
          contentContainerClassName="px-6 py-10"
        >
          <Text className="text-lg font-semibold text-muted-foreground">
            suiss Support
          </Text>
          <Text
            role="heading"
            className="mt-1.5 text-3xl font-semibold tracking-tight"
          >
            How can we help you?
          </Text>
          <Image
            source={{ uri: IMAGE }}
            resizeMode="cover"
            accessible
            accessibilityLabel="A phone resting on a soft beige surface"
            accessibilityIgnoresInvertColors
            className="mt-6 h-[13.75rem] w-full rounded-[1.125rem] bg-secondary"
          />
        </ScrollView>
        {height > 0 ? (
          <AnimatedView
            pointerEvents={open ? "auto" : "none"}
            accessibilityViewIsModal={open}
            accessibilityElementsHidden={!open}
            importantForAccessibility={open ? "auto" : "no-hide-descendants"}
            accessibilityLabel="suiss Support chat"
            className="absolute inset-x-3 bottom-3 overflow-hidden rounded-[1.375rem] border border-border bg-background shadow-lg"
            style={[
              {
                height: panelHeight,
                transformOrigin: I18nManager.isRTL
                  ? "left bottom"
                  : "right bottom",
              },
              panelStyle,
            ]}
          >
            <MessageScrollerProvider>
              <View className="flex-row items-center gap-2.5 border-b border-border px-3.5 py-3">
                <View className="size-[2.125rem] items-center justify-center rounded-full bg-foreground">
                  <Text className="text-base font-bold text-background">s</Text>
                </View>
                <View className="flex-1">
                  <Text role="heading" className="text-sm font-semibold">
                    suiss Support
                  </Text>
                  <View className="flex-row items-center gap-1">
                    <View className="size-1.5 rounded-full bg-green-500" />
                    <Text className="text-xs text-green-500">Online</Text>
                  </View>
                </View>
                <Button
                  variant="secondary"
                  size="icon-sm"
                  accessibilityLabel="Close chat"
                  onPress={() => change(false)}
                  className="size-7"
                >
                  <Icon
                    as={XIcon}
                    weight="bold"
                    className="size-[0.8125rem] text-muted-foreground"
                  />
                </Button>
              </View>
              <MessageScroller>
                <MessageScrollerViewport
                  data={rows}
                  keyExtractor={(row) => row.id}
                  accessibilityLabel="Support conversation"
                  contentContainerClassName="gap-1 px-1 py-3"
                  renderItem={({ item }) => (
                    <MessageScrollerItem>
                      {item.kind === "typing" ? (
                        <TypingDots />
                      ) : (
                        <AnimatedView
                          entering={FadeInDown.duration(700)
                            .easing(Easing.bezier(0.32, 0.72, 0, 1))
                            .reduceMotion(ReduceMotion.System)}
                        >
                          <Message align={item.from === "me" ? "end" : "start"}>
                            <MessageContent>
                              <Bubble
                                variant={
                                  item.from === "me" ? "default" : "muted"
                                }
                                align={item.from === "me" ? "end" : "start"}
                              >
                                <BubbleContent>
                                  <Text className="text-sm">{item.text}</Text>
                                </BubbleContent>
                              </Bubble>
                            </MessageContent>
                          </Message>
                        </AnimatedView>
                      )}
                    </MessageScrollerItem>
                  )}
                />
              </MessageScroller>
              {quick.length > 0 ? (
                <ScrollView
                  horizontal
                  showsHorizontalScrollIndicator={false}
                  keyboardShouldPersistTaps="handled"
                  className="grow-0"
                  contentContainerClassName="gap-1.5 px-3.5 pb-1"
                >
                  {quick.map((question) => (
                    <Button
                      key={question}
                      variant="outline"
                      size="sm"
                      onPress={() => ask(question)}
                      className="border-primary"
                    >
                      <Text className="text-primary">{question}</Text>
                    </Button>
                  ))}
                </ScrollView>
              ) : null}
              <Composer
                onSend={ask}
                onAttach={onAttach}
                onFocusChange={setFocused}
              />
            </MessageScrollerProvider>
          </AnimatedView>
        ) : null}
        <AnimatedView
          pointerEvents={open ? "none" : "auto"}
          accessibilityElementsHidden={open}
          importantForAccessibility={open ? "no-hide-descendants" : "auto"}
          className="absolute end-6 bottom-6"
          style={launcherStyle}
        >
          <Button
            size="icon-xl"
            accessibilityLabel="Open chat"
            accessibilityState={{ expanded: open }}
            onPress={() => change(true)}
            className="rounded-full shadow-lg"
          >
            <Icon as={ChatCircleIcon} weight="fill" className="size-6" />
          </Button>
        </AnimatedView>
      </View>
    </Screen>
  )
}

export { ChatSupport }
```

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: [Bottom Bar](https://ui.suiss.app/docs/components/bottom-bar), [Bubble](https://ui.suiss.app/docs/components/bubble), [Button](https://ui.suiss.app/docs/components/button), [Icon](https://ui.suiss.app/docs/components/icon), [Input Group](https://ui.suiss.app/docs/components/input-group), [Message](https://ui.suiss.app/docs/components/message), [Message Scroller](https://ui.suiss.app/docs/components/message-scroller), [Screen](https://ui.suiss.app/docs/components/screen), [Text](https://ui.suiss.app/docs/components/text), 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/blocks/chat-support.tsx`:

```tsx title="stylesheet/components/blocks/chat-support.tsx"
import {
  ArrowUpIcon,
  ChatCircleIcon,
  PlusIcon,
  XIcon,
} from "phosphor-react-native"
import * as React from "react"
import { I18nManager, Image, ScrollView, StyleSheet, View } from "react-native"
import {
  cancelAnimation,
  Easing,
  FadeInDown,
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withDelay,
  withRepeat,
  withSequence,
  withTiming,
} from "react-native-reanimated"
import { AnimatedView } from "@/components/ui/animated-view"
import { BottomBar, BottomBarContent } from "@/components/ui/bottom-bar"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"
import { Icon } from "@/components/ui/icon"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
} from "@/components/ui/input-group"
import { Message, MessageContent } from "@/components/ui/message"
import {
  MessageScroller,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
  useMessageScroller,
} from "@/components/ui/message-scroller"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"
import {
  fontSize,
  palette,
  radius,
  shadow,
  useTheme,
} from "@/components/ui/theme"

const IMAGE =
  "https://images.unsplash.com/photo-1634403665443-81dc4d75843a?w=900&q=80&auto=format&fit=crop"

type Line = { id: string; from: "me" | "them"; text: string }

type Row = { id: string; kind: "typing" } | ({ kind: "line" } & Line)

const ANSWERS: Record<string, string> = {
  "Where's my order?":
    "Order W1048 has shipped. It arrives tomorrow between 10 AM and 2 PM.",
  "Book a repair":
    "The closest Help Desk is at suiss Union Square. Does tomorrow at 11:30 AM work?",
  "I want to return something":
    "You can return items free of charge within 14 days of delivery. Should I email you a return label?",
}

const QUICK = Object.keys(ANSWERS)

const FALLBACK =
  "Got it. I'm connecting you with a Specialist, this may take a few minutes."

const PANEL_HEIGHT = 520

const PANEL_EASING = Easing.bezier(0.3, 1.15, 0.5, 1)

const LAUNCHER_EASING = Easing.bezier(0.3, 1.25, 0.5, 1)

const SEND_EASING = Easing.bezier(0.3, 1.25, 0.5, 1)

const styles = StyleSheet.create({
  dot: { width: 8, height: 8, borderRadius: radius.full },
  dots: {
    flexDirection: "row",
    gap: 4,
    paddingHorizontal: 14,
    paddingVertical: 12,
  },
  composer: {
    flexDirection: "row",
    alignItems: "center",
    gap: 8,
    paddingHorizontal: 12,
    paddingTop: 8,
    paddingBottom: 12,
  },
  group: { flex: 1, height: 36, borderRadius: radius.full },
  input: { paddingStart: 14 },
  addon: { paddingEnd: 4 },
  send: { width: 27, height: 27 },
  fill: { flex: 1 },
  hero: { paddingHorizontal: 24, paddingVertical: 40 },
  title: { ...fontSize["3xl"], fontWeight: "600", marginTop: 6 },
  image: { marginTop: 24, height: 220, width: "100%", borderRadius: 18 },
  panel: {
    position: "absolute",
    start: 12,
    end: 12,
    bottom: 12,
    overflow: "hidden",
    borderRadius: 22,
    borderWidth: 1,
  },
  panelHeader: {
    flexDirection: "row",
    alignItems: "center",
    gap: 10,
    borderBottomWidth: 1,
    paddingHorizontal: 14,
    paddingVertical: 12,
  },
  logo: {
    width: 34,
    height: 34,
    alignItems: "center",
    justifyContent: "center",
    borderRadius: radius.full,
  },
  status: { flexDirection: "row", alignItems: "center", gap: 4 },
  statusDot: { width: 6, height: 6, borderRadius: radius.full },
  close: { width: 28, height: 28 },
  list: { gap: 4, paddingHorizontal: 4, paddingVertical: 12 },
  quick: { flexGrow: 0 },
  quickContent: { gap: 6, paddingHorizontal: 14, paddingBottom: 4 },
  launcher: { position: "absolute", end: 24, bottom: 24 },
  launcherButton: { borderRadius: radius.full },
})

function TypingDot({ index }: { index: number }) {
  const { colors } = useTheme()
  const opacity = useSharedValue(0.35)

  React.useEffect(() => {
    opacity.value = withDelay(
      index * 180,
      withRepeat(
        withSequence(
          withTiming(1, { duration: 550 }),
          withTiming(0.35, { duration: 550 })
        ),
        -1,
        false,
        undefined,
        ReduceMotion.System
      )
    )
    return () => cancelAnimation(opacity)
  }, [index, opacity])

  const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value }))

  return (
    <AnimatedView
      style={[
        styles.dot,
        { backgroundColor: colors.mutedForeground },
        animatedStyle,
      ]}
    />
  )
}

function TypingDots() {
  return (
    <Message>
      <MessageContent>
        <Bubble variant="muted" accessible accessibilityLabel="Typing">
          <BubbleContent style={styles.dots}>
            {[0, 1, 2].map((dot) => (
              <TypingDot key={dot} index={dot} />
            ))}
          </BubbleContent>
        </Bubble>
      </MessageContent>
    </Message>
  )
}

function Composer({
  onSend,
  onAttach,
  onFocusChange,
}: {
  onSend: (text: string) => void
  onAttach?: () => void
  onFocusChange: (focused: boolean) => void
}) {
  const { colors } = useTheme()
  const [draft, setDraft] = React.useState("")
  const { scrollToEnd } = useMessageScroller()
  const ready = draft.trim().length > 0
  const scale = useSharedValue(ready ? 1 : 0)

  React.useEffect(() => {
    scale.value = withTiming(ready ? 1 : 0, {
      duration: 300,
      easing: SEND_EASING,
      reduceMotion: ReduceMotion.System,
    })
  }, [ready, scale])

  const sendStyle = useAnimatedStyle(() => ({
    transform: [{ scale: scale.value }],
  }))

  const send = () => {
    if (!ready) return
    onSend(draft.trim())
    setDraft("")
    requestAnimationFrame(() => scrollToEnd())
  }

  return (
    <BottomBar>
      <BottomBarContent style={styles.composer}>
        <Button
          variant="secondary"
          size="icon-sm"
          accessibilityLabel="Add attachment"
          onPress={onAttach}
        >
          <Icon
            as={PlusIcon}
            weight="bold"
            size={18}
            color={colors.mutedForeground}
          />
        </Button>
        <InputGroup style={styles.group}>
          <InputGroupInput
            value={draft}
            onChangeText={setDraft}
            placeholder="Type your question"
            accessibilityLabel="Type your question"
            returnKeyType="send"
            submitBehavior="submit"
            onSubmitEditing={send}
            onFocus={() => onFocusChange(true)}
            onBlur={() => onFocusChange(false)}
            style={styles.input}
          />
          <InputGroupAddon align="inline-end" style={styles.addon}>
            <AnimatedView style={sendStyle}>
              <InputGroupButton
                variant="default"
                size="icon-xs"
                accessibilityLabel="Send"
                disabled={!ready}
                onPress={send}
                style={styles.send}
              >
                <Icon as={ArrowUpIcon} weight="bold" size={16} />
              </InputGroupButton>
            </AnimatedView>
          </InputGroupAddon>
        </InputGroup>
      </BottomBarContent>
    </BottomBar>
  )
}

function ChatSupport({
  onSend,
  onAttach,
  onOpenChange,
}: {
  onSend?: (text: string) => void
  onAttach?: () => void
  onOpenChange?: (open: boolean) => void
}) {
  const { colors } = useTheme()
  const [open, setOpen] = React.useState(true)
  const [lines, setLines] = React.useState<Line[]>([
    {
      id: "0",
      from: "them",
      text: "Hi Jamie, I'm the suiss Support assistant. How can I help?",
    },
  ])
  const [typing, setTyping] = React.useState(false)
  const [height, setHeight] = React.useState(0)
  const [focused, setFocused] = React.useState(false)
  const timers = React.useRef<ReturnType<typeof setTimeout>[]>([])
  const panel = useSharedValue(1)
  const launcher = useSharedValue(0)

  React.useEffect(
    () => () => {
      for (const timer of timers.current) clearTimeout(timer)
    },
    []
  )

  React.useEffect(() => {
    panel.value = withTiming(open ? 1 : 0, {
      duration: open ? 500 : 250,
      easing: open ? PANEL_EASING : Easing.ease,
      reduceMotion: ReduceMotion.System,
    })
    launcher.value = withTiming(open ? 0 : 1, {
      duration: 400,
      easing: LAUNCHER_EASING,
      reduceMotion: ReduceMotion.System,
    })
  }, [open, panel, launcher])

  const panelStyle = useAnimatedStyle(() => ({
    opacity: Math.min(1, panel.value * 2),
    transform: [{ scale: 0.2 + panel.value * 0.8 }],
  }))

  const launcherStyle = useAnimatedStyle(() => ({
    transform: [{ scale: launcher.value }],
  }))

  const change = (next: boolean) => {
    setOpen(next)
    onOpenChange?.(next)
  }

  const ask = (question: string) => {
    onSend?.(question)
    setLines((current) => [
      ...current,
      { id: String(current.length), from: "me", text: question },
    ])
    setTyping(true)
    timers.current.push(
      setTimeout(() => {
        setTyping(false)
        setLines((current) => [
          ...current,
          {
            id: String(current.length),
            from: "them",
            text: ANSWERS[question] ?? FALLBACK,
          },
        ])
      }, 1200)
    )
  }

  const rows: Row[] = [
    ...lines.map((line) => ({ kind: "line" as const, ...line })),
    ...(typing ? [{ id: "typing", kind: "typing" as const }] : []),
  ]
  const quick = QUICK.filter(
    (question) => !lines.some((line) => line.text === question)
  )
  const available = Math.max(0, height - 24)
  const panelHeight = focused ? available : Math.min(PANEL_HEIGHT, available)

  return (
    <Screen variant="secondary">
      <View
        style={styles.fill}
        onLayout={(event) => setHeight(event.nativeEvent.layout.height)}
      >
        <ScrollView
          accessibilityElementsHidden={open}
          importantForAccessibility={open ? "no-hide-descendants" : "auto"}
          contentContainerStyle={styles.hero}
        >
          <Text
            style={[
              fontSize.lg,
              { fontWeight: "600", color: colors.mutedForeground },
            ]}
          >
            suiss Support
          </Text>
          <Text role="heading" style={styles.title}>
            How can we help you?
          </Text>
          <Image
            source={{ uri: IMAGE }}
            resizeMode="cover"
            accessible
            accessibilityLabel="A phone resting on a soft beige surface"
            accessibilityIgnoresInvertColors
            style={[styles.image, { backgroundColor: colors.secondary }]}
          />
        </ScrollView>
        {height > 0 ? (
          <AnimatedView
            pointerEvents={open ? "auto" : "none"}
            accessibilityViewIsModal={open}
            accessibilityElementsHidden={!open}
            importantForAccessibility={open ? "auto" : "no-hide-descendants"}
            accessibilityLabel="suiss Support chat"
            style={[
              styles.panel,
              shadow.lg,
              {
                height: panelHeight,
                borderColor: colors.border,
                backgroundColor: colors.background,
                transformOrigin: I18nManager.isRTL
                  ? "left bottom"
                  : "right bottom",
              },
              panelStyle,
            ]}
          >
            <MessageScrollerProvider>
              <View
                style={[
                  styles.panelHeader,
                  { borderBottomColor: colors.border },
                ]}
              >
                <View
                  style={[styles.logo, { backgroundColor: colors.foreground }]}
                >
                  <Text
                    style={[
                      fontSize.base,
                      { fontWeight: "700", color: colors.background },
                    ]}
                  >
                    s
                  </Text>
                </View>
                <View style={styles.fill}>
                  <Text
                    role="heading"
                    style={[fontSize.sm, { fontWeight: "600" }]}
                  >
                    suiss Support
                  </Text>
                  <View style={styles.status}>
                    <View
                      style={[
                        styles.statusDot,
                        { backgroundColor: palette.green500 },
                      ]}
                    />
                    <Text style={[fontSize.xs, { color: palette.green500 }]}>
                      Online
                    </Text>
                  </View>
                </View>
                <Button
                  variant="secondary"
                  size="icon-sm"
                  accessibilityLabel="Close chat"
                  onPress={() => change(false)}
                  style={styles.close}
                >
                  <Icon
                    as={XIcon}
                    weight="bold"
                    size={13}
                    color={colors.mutedForeground}
                  />
                </Button>
              </View>
              <MessageScroller>
                <MessageScrollerViewport
                  data={rows}
                  keyExtractor={(row) => row.id}
                  accessibilityLabel="Support conversation"
                  contentContainerStyle={styles.list}
                  renderItem={({ item }) => (
                    <MessageScrollerItem>
                      {item.kind === "typing" ? (
                        <TypingDots />
                      ) : (
                        <AnimatedView
                          entering={FadeInDown.duration(700)
                            .easing(Easing.bezier(0.32, 0.72, 0, 1))
                            .reduceMotion(ReduceMotion.System)}
                        >
                          <Message align={item.from === "me" ? "end" : "start"}>
                            <MessageContent>
                              <Bubble
                                variant={
                                  item.from === "me" ? "default" : "muted"
                                }
                                align={item.from === "me" ? "end" : "start"}
                              >
                                <BubbleContent>
                                  <Text style={fontSize.sm}>{item.text}</Text>
                                </BubbleContent>
                              </Bubble>
                            </MessageContent>
                          </Message>
                        </AnimatedView>
                      )}
                    </MessageScrollerItem>
                  )}
                />
              </MessageScroller>
              {quick.length > 0 ? (
                <ScrollView
                  horizontal
                  showsHorizontalScrollIndicator={false}
                  keyboardShouldPersistTaps="handled"
                  style={styles.quick}
                  contentContainerStyle={styles.quickContent}
                >
                  {quick.map((question) => (
                    <Button
                      key={question}
                      variant="outline"
                      size="sm"
                      onPress={() => ask(question)}
                      style={{ borderColor: colors.primary }}
                    >
                      <Text style={{ color: colors.primary }}>{question}</Text>
                    </Button>
                  ))}
                </ScrollView>
              ) : null}
              <Composer
                onSend={ask}
                onAttach={onAttach}
                onFocusChange={setFocused}
              />
            </MessageScrollerProvider>
          </AnimatedView>
        ) : null}
        <AnimatedView
          pointerEvents={open ? "none" : "auto"}
          accessibilityElementsHidden={open}
          importantForAccessibility={open ? "no-hide-descendants" : "auto"}
          style={[styles.launcher, launcherStyle]}
        >
          <Button
            size="icon-xl"
            accessibilityLabel="Open chat"
            accessibilityState={{ expanded: open }}
            onPress={() => change(true)}
            style={[styles.launcherButton, shadow.lg]}
          >
            <Icon as={ChatCircleIcon} weight="fill" size={24} />
          </Button>
        </AnimatedView>
      </View>
    </Screen>
  )
}

export { ChatSupport }
```

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 and every file it uses:

- `swift/Blocks/ChatSupport.swift`: shown above

- `swift/Components/Bubble.swift`: see [Bubble](https://ui.suiss.app/docs/components/bubble.md)

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

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

- `swift/Components/Checkbox.swift`: see [Checkbox](https://ui.suiss.app/docs/components/checkbox.md)

- `swift/Components/Fab.swift`: see [FAB](https://ui.suiss.app/docs/components/fab.md)

- `swift/Components/Field.swift`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `swift/Components/Haptics.swift`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `swift/Components/InputGroup.swift`: see [Input Group](https://ui.suiss.app/docs/components/input-group.md)

- `swift/Components/KeyboardScrollView.swift`: see [Keyboard Scroll View](https://ui.suiss.app/docs/components/keyboard-scroll-view.md)

- `swift/Components/Message.swift`: see [Message](https://ui.suiss.app/docs/components/message.md)

- `swift/Components/MessageScroller.swift`: see [Message Scroller](https://ui.suiss.app/docs/components/message-scroller.md)

- `swift/Components/Screen.swift`: see [Screen](https://ui.suiss.app/docs/components/screen.md)

- `swift/Components/Separator.swift`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

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

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:

- `kotlin/blocks/ChatSupport.kt`: shown above

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

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

- `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/Field.kt`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `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/Haptics.kt`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

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

- `kotlin/components/InputGroup.kt`: see [Input Group](https://ui.suiss.app/docs/components/input-group.md)

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

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

- `kotlin/components/MessageScroller.kt`: see [Message Scroller](https://ui.suiss.app/docs/components/message-scroller.md)

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

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

- `kotlin/components/SignaturePad.kt`: see [Signature Pad](https://ui.suiss.app/docs/components/signature-pad.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 { ChatSupport } from "@/components/blocks/chat-support"
```

```tsx
<ChatSupport />
```

Replace `ANSWERS` and `FALLBACK` with your own replies or a call to your support service. Pass `onSend`, `onAttach` and `onOpenChange` to handle messages, attachments and the open state.

